Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff0018cf43 |
@@ -142,7 +142,7 @@ TTY detection, and security headers are always applied.
|
||||
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive) | `1h` |
|
||||
| `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` |
|
||||
| `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) |
|
||||
|
||||
#### Allowing egress to your own network
|
||||
@@ -157,21 +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
|
||||
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it.
|
||||
|
||||
That is all the default blocklist covers: the IPv4 private and reserved
|
||||
ranges; of IPv6, only loopback (`::1`), unique local addresses
|
||||
(`fc00::/7`) and link-local addresses (`fe80::/10`); and certain public
|
||||
addresses. A public address belongs on the default blocklist only if it
|
||||
hands credentials, user data or bootstrap material to whatever can reach
|
||||
it, without the caller presenting anything. A provider's other public
|
||||
addresses are not refused. IBM Cloud, for example, serves its package
|
||||
mirrors, time servers and object storage on `161.26.0.0/16`, and the
|
||||
private endpoints of its own cloud services on `166.8.0.0/14`. Neither
|
||||
range hands out credentials that way: the token service among those
|
||||
endpoints issues a token only in exchange for something the caller
|
||||
presents, such as an API key. Reaching these services can be a
|
||||
legitimate delivery, and every cloud has some, so a partial list would
|
||||
promise coverage it does not give.
|
||||
|
||||
That default is also inconvenient for the thing webhooker is mostly
|
||||
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
|
||||
@@ -389,37 +374,41 @@ unlocked.
|
||||
`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
|
||||
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
|
||||
the addresses of your reverse proxies.
|
||||
`X-Forwarded-For` header the rate limiters believe, so it should name
|
||||
the addresses of your reverse proxies and nothing else.
|
||||
|
||||
`X-Forwarded-For` is honoured **only** when the connecting peer 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
|
||||
empty), the list is the RFC 1918 private ranges: `10.0.0.0/8`,
|
||||
`172.16.0.0/12` and `192.168.0.0/16`. A set value replaces the default
|
||||
entirely. A set but unparseable value aborts startup.
|
||||
the connection's own address and the header is ignored. The default is
|
||||
the empty list, which trusts nobody — anything else would let any
|
||||
client pick its own rate limit bucket, minting a fresh one per request
|
||||
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
|
||||
RFC 1918 source address (directly, or through anything that can
|
||||
rewrite source addresses, such as NAT or a published container port),
|
||||
set `TRUSTED_PROXIES` to the proxy's address alone, or every rate
|
||||
limit, the webhook receiver's included, can be bypassed by those
|
||||
clients. The address to set is the `remoteIP` field of the
|
||||
`http request` log line for a request that came through the proxy.
|
||||
|
||||
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.
|
||||
That default is safe against forged headers, but leaving it unset in
|
||||
production has a cost you must know about. Production runs behind a
|
||||
TLS-terminating reverse proxy, so with `TRUSTED_PROXIES` unset 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.
|
||||
|
||||
What it cannot do is lock the operator out. The login endpoint
|
||||
verifies credentials **before** it consults any limit and charges only
|
||||
failures, so a correct password is never throttled no matter how full
|
||||
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.
|
||||
Reverse proxies append to `X-Forwarded-For` but forward other client
|
||||
headers verbatim, so a single-valued header is client-controlled even
|
||||
@@ -435,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
|
||||
every hop in it is a trusted proxy.
|
||||
|
||||
Your proxy must therefore **append** the peer address to
|
||||
`X-Forwarded-For` (nginx `$proxy_add_x_forwarded_for`, HAProxy
|
||||
`option forwardfor`, Caddy and AWS ALB by default), and must append a
|
||||
bare address with no port.
|
||||
Two operator requirements follow:
|
||||
|
||||
- Your proxy must **append** the peer address to `X-Forwarded-For`
|
||||
(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
|
||||
|
||||
@@ -737,15 +736,10 @@ repository's `Dockerfile` and runs it. The app needs:
|
||||
- **Volume:** one host directory mounted at `/var/lib/webhooker`.
|
||||
- **Environment variables:**
|
||||
- `WEBHOOKER_ENVIRONMENT=prod`
|
||||
- `TRUSTED_PROXIES`: unset, it is the RFC 1918 ranges. Set it to
|
||||
your reverse proxy's address alone if that address is outside
|
||||
those ranges, or if any client can reach webhooker, or the proxy,
|
||||
from an RFC 1918 source address (directly, or through anything
|
||||
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).
|
||||
- `TRUSTED_PROXIES`: your reverse proxy's address on that Docker
|
||||
network. The `remoteIP` field of the `http request` log line for a
|
||||
request that came through the proxy shows it; the health check's
|
||||
own lines show `::1`. See [Trusted proxies](#trusted-proxies).
|
||||
- Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets
|
||||
`BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to
|
||||
`/var/lib/webhooker`.
|
||||
@@ -808,16 +802,12 @@ reports.
|
||||
behind a proxy means the `X-Forwarded-Proto` header. The block below
|
||||
sets it; without it every request is read as plaintext and cookies
|
||||
ship without `Secure`. See [Configuration](#configuration).
|
||||
3. **Make sure `TRUSTED_PROXIES` covers the proxy's address.** For a
|
||||
proxy it does not cover, every rate limiter keys on the proxy, so
|
||||
all clients share one bucket per limit. Unset, the list is the RFC
|
||||
1918 ranges, which do not cover a proxy that reaches the binary
|
||||
itself over loopback (the binary bound to `127.0.0.1`). With the
|
||||
image, the address to check is the `remoteIP` field of the
|
||||
`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).
|
||||
3. **Set `TRUSTED_PROXIES` to the proxy's address.** Unset, every rate
|
||||
limiter keys on the connecting peer, which behind a proxy is the
|
||||
proxy on every request: all clients collapse into one global bucket
|
||||
per limit and the receiver's per-IP limits become service-wide
|
||||
ceilings. See [Trusted proxies](#trusted-proxies). List the proxy
|
||||
and nothing else.
|
||||
4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the
|
||||
port. webhooker's Origin/Referer check compares against the host it
|
||||
was given, so on any port other than 443 `$host` makes every form
|
||||
@@ -1370,11 +1360,10 @@ It uses:
|
||||
- **[go-chi/httprate](https://github.com/go-chi/httprate)** for
|
||||
sliding-window rate limiting of the password-change and webhook
|
||||
receiver endpoints. The bucket is per client IP only when
|
||||
`TRUSTED_PROXIES` covers the reverse proxy (by default it covers the
|
||||
RFC 1918 private ranges); otherwise every client behind that proxy
|
||||
shares one bucket per limit. The login endpoint counts failed
|
||||
attempts itself instead, so that a correct password is never
|
||||
throttled (see [Rate Limiting](#rate-limiting))
|
||||
`TRUSTED_PROXIES` names the reverse proxy; unset, every client
|
||||
behind that proxy shares one bucket per limit. The login endpoint
|
||||
counts failed attempts itself instead, so that a correct password is
|
||||
never throttled (see [Rate Limiting](#rate-limiting))
|
||||
- **[Prometheus](https://prometheus.io)** for metrics, served at
|
||||
`/metrics` behind basic auth
|
||||
- **[Sentry](https://sentry.io)** for optional error reporting
|
||||
@@ -1660,7 +1649,6 @@ data for auditing, for replay, and for resubmission.
|
||||
| `headers` | JSON | Complete request headers |
|
||||
| `body` | text | Raw request body |
|
||||
| `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 |
|
||||
|
||||
**Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many
|
||||
@@ -1754,11 +1742,10 @@ retries) is individually logged for full observability.
|
||||
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 |
|
||||
| Field | Type | Description |
|
||||
| ---------------- | ------- | ----------- |
|
||||
| `events` | integer | Events ever stored, resubmitted copies included |
|
||||
| `events_removed` | integer | Events retention has deleted |
|
||||
|
||||
`TargetTotals` is one row per target, created by the first delivery to it:
|
||||
|
||||
@@ -1775,9 +1762,7 @@ 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
|
||||
removed, so neither needs the rows themselves. Its last-10-minutes and
|
||||
last-24-hours figures are counted from the `events` and `deliveries`
|
||||
indexes over just that window, the deliveries in one query grouped by
|
||||
target. Its failure percentage for a window is the deliveries that became
|
||||
@@ -1794,25 +1779,23 @@ tags, so `AutoMigrate` creates them on a fresh database:
|
||||
| `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` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts 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 |
|
||||
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
||||
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events and find the newest |
|
||||
| `events` | `created_at` | Retention, which selects expired events by age |
|
||||
|
||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
|
||||
leaves it out. SQLite keeps no statistics on these tables, and without them it
|
||||
rates the `deleted_at` index, which every live row matches, above an index on
|
||||
a column matched against several values or compared with a range. So every
|
||||
index but the last also covers `deleted_at`. It comes second, so that
|
||||
retention can use the index without it, except in `events`, where the
|
||||
statistics compare `created_at` with a range (`>=`) and SQLite narrows by a
|
||||
range only on the last column it uses.
|
||||
a column matched against several values or compared with `<`. So every index
|
||||
but the last also covers `deleted_at`. It comes second, so that retention can
|
||||
use the index without it, except in `events`, where `created_at` is compared
|
||||
with `<` and SQLite narrows by a `<` only on the last column it uses.
|
||||
|
||||
#### Common Fields
|
||||
|
||||
Every entity except `Setting`, `EventTotals` and `TargetTotals` includes
|
||||
these fields from `BaseModel`. `Setting` is a bare key-value row with no
|
||||
`id`, no timestamps and no soft delete, and the two totals tables hold
|
||||
counts, plus `last_event_at` in `event_totals`, keyed by a numeric `id`
|
||||
and by `target_id`:
|
||||
only counts, keyed by a numeric `id` and by `target_id`:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------ | --------- | ----------- |
|
||||
@@ -2592,44 +2575,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
|
||||
arrived uncut — never the numbers.
|
||||
|
||||
Every limiter here — receiver, login, password change, delivery replay
|
||||
and event resubmit — identifies the client the same way, through one
|
||||
shared key function: the connection's own address, unless the peer is
|
||||
inside `TRUSTED_PROXIES`, in which case the forwarded client address is
|
||||
used instead. That address becomes a bucket by family: IPv4 keys on
|
||||
the full address, IPv6 on its `/64` prefix. A routed `/64` is the normal
|
||||
Every limiter here — receiver, login, and password change — identifies
|
||||
the client the same way, through one shared key function: the
|
||||
connection's own address, unless the peer is listed in
|
||||
`TRUSTED_PROXIES`, in which case the forwarded client address is used
|
||||
instead. That address becomes a bucket by family: IPv4 keys on the full
|
||||
address, IPv6 on its `/64` prefix. A routed `/64` is the normal
|
||||
residential and mobile IPv6 allocation, so keying IPv6 per address would
|
||||
let one subscriber rotate source addresses and mint a fresh bucket per
|
||||
request, evading these limits at the network layer without spoofing
|
||||
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
|
||||
they carry. See [Trusted proxies](#trusted-proxies). When that variable
|
||||
does not cover the reverse proxy, a client behind it shares one bucket
|
||||
with every other client behind the same proxy. Set `TRUSTED_PROXIES` to
|
||||
the proxy's address to get per-client limits back. What the shared bucket
|
||||
they carry. See [Trusted proxies](#trusted-proxies). Deployed without that
|
||||
variable set, a client behind a reverse proxy shares one bucket with
|
||||
every other client behind the same proxy. Set `TRUSTED_PROXIES` to the
|
||||
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
|
||||
opposite directions:
|
||||
|
||||
- For the **receiver** limits it costs throughput, which is the safe
|
||||
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
|
||||
than for the per-entrypoint one: with every request keyed on the
|
||||
proxy, the aggregate limit becomes a service-wide ceiling of 1200
|
||||
requests per minute across all senders and all entrypoints, where the
|
||||
per-entrypoint limit's capacity still grows with the number of
|
||||
entrypoints.
|
||||
than for the per-entrypoint one: with `TRUSTED_PROXIES` unset behind
|
||||
the reverse proxy a production deployment is required to run behind,
|
||||
every request keys on the proxy, so the aggregate limit becomes a
|
||||
service-wide ceiling of 1200 requests per minute across all senders
|
||||
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
|
||||
availability. Login failures from every client land in one counter,
|
||||
so a stranger's wrong passwords make the operator's own wrong
|
||||
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 `POST` is the one endpoint with no pre-emptive limiter in
|
||||
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
|
||||
behind a reverse proxy that `TRUSTED_PROXIES` does not cover: a
|
||||
arrival is a lockout in this deployment shape: sharing one bucket, a
|
||||
stranger sending five POSTs a minute — about 0.08 requests per second,
|
||||
from anywhere — keeps it permanently full, and the operator has no
|
||||
second administrative path. So the handler inverts the order:
|
||||
@@ -2726,10 +2712,8 @@ re-fills both verification slots on its first two requests. The
|
||||
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
|
||||
applied without reintroducing the lockout, because the proxy sees the
|
||||
real client address. `TRUSTED_PROXIES` does not stop the saturation.
|
||||
The flood's source is in the proxy's access log: webhooker's own logs
|
||||
record the proxy's address, not the client's (see
|
||||
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)).
|
||||
real client address. Setting `TRUSTED_PROXIES` does not stop the
|
||||
saturation, but it makes the source visible in the failure logs.
|
||||
|
||||
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
|
||||
@@ -3088,9 +3072,10 @@ check, see [The login endpoint](#the-login-endpoint).
|
||||
It runs behind session auth, so only a client already holding a
|
||||
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
|
||||
`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
|
||||
(see [Rate Limiting](#rate-limiting))
|
||||
(see [Rate Limiting](#rate-limiting)). webhooker warns at startup
|
||||
whenever `TRUSTED_PROXIES` is empty
|
||||
- Prometheus metrics behind basic auth
|
||||
- Static assets embedded in binary (no filesystem access needed at
|
||||
runtime)
|
||||
@@ -3100,8 +3085,7 @@ check, see [The login endpoint](#the-login-endpoint).
|
||||
before the app starts; the image's health check; and `docker exec`,
|
||||
unless given `--user`
|
||||
- GORM soft deletes on every entity that carries `BaseModel`, which is
|
||||
all of them but `Setting`, `EventTotals` and `TargetTotals` (data
|
||||
preserved for audit)
|
||||
all of them but `Setting` (data preserved for audit)
|
||||
|
||||
### Shutdown
|
||||
|
||||
|
||||
@@ -387,7 +387,7 @@ point of the branch.
|
||||
- 2026-03-05 security headers middleware, session regeneration on
|
||||
login, request body size limits (#41)
|
||||
- 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
|
||||
delivery engine with bounded worker pool and circuit breaker,
|
||||
parallel fan-out, per-webhook event databases, management UI (#16)
|
||||
|
||||
@@ -4,7 +4,6 @@ go 1.26.1
|
||||
|
||||
require (
|
||||
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/go-chi/chi v1.5.5
|
||||
github.com/go-chi/cors v1.2.1
|
||||
@@ -30,6 +29,7 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // 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/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
|
||||
+60
-22
@@ -75,11 +75,6 @@ const (
|
||||
// internet-exposed endpoint.
|
||||
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
|
||||
// bound (at least 1) is enforced by envPositiveInt.
|
||||
maxPort = 65535
|
||||
@@ -177,14 +172,13 @@ type Config struct {
|
||||
|
||||
// TrustedProxies is the set of networks whose members are
|
||||
// allowed to speak for the client with X-Forwarded-For, the
|
||||
// only forwarded header read. Unless TRUSTED_PROXIES is set it
|
||||
// is the RFC 1918 private ranges (defaultTrustedProxies); a set
|
||||
// value replaces them. If any client can reach the process, or
|
||||
// the proxy in front of it, from an RFC 1918 source address
|
||||
// (directly, or through anything that can rewrite source
|
||||
// addresses, such as NAT or a published container port), it
|
||||
// must be set to the proxy's address alone, or every rate limit
|
||||
// can be bypassed by those clients.
|
||||
// only forwarded header read. It is empty unless
|
||||
// TRUSTED_PROXIES is set, and empty means no peer is
|
||||
// trusted: forwarded headers are then ignored entirely and
|
||||
// clients are identified by the connection's own address.
|
||||
// Members can choose their own rate-limit key, so this must
|
||||
// name proxy hosts only, never a block that also covers
|
||||
// clients.
|
||||
TrustedProxies []netip.Prefix
|
||||
|
||||
// AllowedEgressCIDRs is the set of networks a delivery target
|
||||
@@ -466,15 +460,14 @@ func parseCIDR(entry string) (netip.Prefix, error) {
|
||||
|
||||
// envPrefixList returns the value of the named environment variable
|
||||
// parsed as a comma-separated list of CIDR blocks (bare addresses
|
||||
// allowed). An unset, empty, or blank value is read as defaultValue
|
||||
// instead. A set value containing an unparseable entry is a hard
|
||||
// error naming the key and the bad entry, so startup fails loudly
|
||||
// rather than silently running with a list the operator did not
|
||||
// intend.
|
||||
func envPrefixList(key, defaultValue string) ([]netip.Prefix, error) {
|
||||
// allowed). An unset, empty, or blank value yields an empty list. A
|
||||
// set value containing an unparseable entry is a hard error naming
|
||||
// the key and the bad entry, so startup fails loudly rather than
|
||||
// silently running with a list the operator did not intend.
|
||||
func envPrefixList(key string) ([]netip.Prefix, error) {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
v = defaultValue
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var prefixes []netip.Prefix
|
||||
@@ -688,12 +681,12 @@ func loadFromEnv() (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trustedProxies, err := envPrefixList("TRUSTED_PROXIES", defaultTrustedProxies)
|
||||
trustedProxies, err := envPrefixList("TRUSTED_PROXIES")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS", "")
|
||||
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -767,6 +760,50 @@ func (c *Config) warnEgressAllowlist(log *slog.Logger) {
|
||||
)
|
||||
}
|
||||
|
||||
// warnSharedRateLimitBucket logs a startup warning whenever
|
||||
// TRUSTED_PROXIES is empty, in any environment.
|
||||
//
|
||||
// With no trusted proxies every rate limiter keys on the connecting
|
||||
// peer's address. Whether that is harmless or dangerous depends on
|
||||
// what is in front of the process, which this code cannot observe:
|
||||
// with nothing in front, the peer is the client and the limits are
|
||||
// per-client as intended; behind a reverse proxy the peer is the proxy
|
||||
// for every request, so all clients share one bucket per limiter.
|
||||
//
|
||||
// The login endpoint no longer spends budget on arrival — it verifies
|
||||
// credentials first and charges only failures — so a shared bucket
|
||||
// cannot deny the operator a correct password. What it does collapse
|
||||
// is the failure counting: one client's wrong passwords throttle
|
||||
// everyone else's wrong passwords, and the receiver's limits become
|
||||
// service-wide ceilings.
|
||||
//
|
||||
// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT:
|
||||
// behind a proxy every client shares one bucket in dev and prod alike.
|
||||
//
|
||||
// The default of trusting nobody is deliberate — trusting forwarded
|
||||
// headers from arbitrary peers lets any client choose its own bucket —
|
||||
// so this warns rather than failing startup or changing the key.
|
||||
func (c *Config) warnSharedRateLimitBucket(log *slog.Logger) {
|
||||
if len(c.TrustedProxies) > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn(
|
||||
"TRUSTED_PROXIES is empty: every rate limit keys on the "+
|
||||
"connecting peer's address. With nothing proxying to "+
|
||||
"this process that is the client itself and the limits "+
|
||||
"are per-client as intended. Behind a reverse proxy the "+
|
||||
"peer is the proxy on every request, so all clients "+
|
||||
"share one bucket per limit: the receiver limits become "+
|
||||
"service-wide ceilings, and one client's failed logins "+
|
||||
"throttle every other client's failed logins — a "+
|
||||
"correct password still gets in. If anything proxies to "+
|
||||
"this process, set TRUSTED_PROXIES to its address.",
|
||||
"environment", c.Environment,
|
||||
"trustedProxies", len(c.TrustedProxies),
|
||||
)
|
||||
}
|
||||
|
||||
// New creates a Config by reading environment variables.
|
||||
//
|
||||
//nolint:revive // lc parameter is required by fx even if unused.
|
||||
@@ -812,6 +849,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
"hasMetricsAuth", s.MetricsAuthEnabled(),
|
||||
)
|
||||
|
||||
s.warnSharedRateLimitBucket(log)
|
||||
s.warnEgressAllowlist(log)
|
||||
|
||||
return s, nil
|
||||
|
||||
+101
-14
@@ -551,11 +551,6 @@ func testReceiverRateLimitSuccess(
|
||||
}
|
||||
|
||||
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 {
|
||||
name string
|
||||
set bool
|
||||
@@ -564,21 +559,18 @@ func TestTrustedProxies(t *testing.T) {
|
||||
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,
|
||||
set: false,
|
||||
expected: defaultProxies,
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "blank value uses default",
|
||||
name: "blank value trusts nothing",
|
||||
set: true,
|
||||
value: " ",
|
||||
expected: defaultProxies,
|
||||
},
|
||||
{
|
||||
name: "set value replaces the default entirely",
|
||||
set: true,
|
||||
value: "203.0.113.7",
|
||||
expected: []string{"203.0.113.7/32"},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: caseValidValueParsed,
|
||||
@@ -853,6 +845,101 @@ func TestEgressAllowlistWarning(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedRateLimitBucketWarning covers the startup warning that
|
||||
// tells an operator a deployment behind a reverse proxy shares one
|
||||
// rate-limit bucket between every client, which turns the receiver
|
||||
// limits into service-wide ceilings and collapses login failure
|
||||
// counting. It must fire whenever TRUSTED_PROXIES is empty, in any
|
||||
// environment, because behind a proxy every client shares one bucket
|
||||
// in dev and prod alike. It stays quiet once proxies are named.
|
||||
func TestSharedRateLimitBucketWarning(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
environment string
|
||||
trustedProxies string
|
||||
expectWarning bool
|
||||
}{
|
||||
{
|
||||
name: "prod without trusted proxies warns",
|
||||
environment: config.EnvironmentProd,
|
||||
expectWarning: true,
|
||||
},
|
||||
{
|
||||
name: "prod with trusted proxies is quiet",
|
||||
environment: config.EnvironmentProd,
|
||||
trustedProxies: cidrPrivateV4,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
name: "dev without trusted proxies warns",
|
||||
environment: config.EnvironmentDev,
|
||||
expectWarning: true,
|
||||
},
|
||||
{
|
||||
name: "dev with trusted proxies is quiet",
|
||||
environment: config.EnvironmentDev,
|
||||
trustedProxies: cidrPrivateV4,
|
||||
expectWarning: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
t.Setenv("WEBHOOKER_ENVIRONMENT", tt.environment)
|
||||
|
||||
if tt.trustedProxies == "" {
|
||||
require.NoError(
|
||||
t, os.Unsetenv("TRUSTED_PROXIES"),
|
||||
)
|
||||
} else {
|
||||
t.Setenv("TRUSTED_PROXIES", tt.trustedProxies)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
log := slog.New(slog.NewJSONHandler(
|
||||
&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
},
|
||||
))
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
config.WarnSharedRateLimitBucketForTest(log),
|
||||
)
|
||||
|
||||
if !tt.expectWarning {
|
||||
assert.Empty(t, buf.String())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logged := buf.String()
|
||||
|
||||
assert.Contains(t, logged, `"level":"WARN"`)
|
||||
assert.Contains(t, logged, "TRUSTED_PROXIES")
|
||||
assert.Contains(t, logged, "share one bucket")
|
||||
assert.Contains(
|
||||
t, logged, "throttle every other client's failed logins",
|
||||
)
|
||||
// The warning must not claim a lockout the login
|
||||
// endpoint no longer permits: credentials are verified
|
||||
// before any budget is spent.
|
||||
assert.Contains(
|
||||
t, logged, "a correct password still gets in",
|
||||
)
|
||||
// The text must stay accurate for a developer with
|
||||
// nothing in front of the process, where an empty
|
||||
// list costs nothing.
|
||||
assert.Contains(
|
||||
t, logged, "nothing proxying to this process",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// metricsEnv describes what one subtest below puts in the
|
||||
// environment for a single METRICS_ variable. A variable that is
|
||||
// set to the empty string and one that is not set at all are
|
||||
|
||||
@@ -6,6 +6,21 @@ import "log/slog"
|
||||
// the external config_test package so each helper can be covered by
|
||||
// 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
|
||||
// environment and emits its egress-allowlist startup warning to
|
||||
// log, so a test can assert both that the warning fires only when
|
||||
|
||||
@@ -152,7 +152,9 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
||||
// 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.
|
||||
// come from the index alone, events received since a time, and the
|
||||
// newest event, which must come straight off an index rather than from
|
||||
// sorting every event.
|
||||
func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -170,6 +172,7 @@ func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
|
||||
|
||||
var (
|
||||
count int64
|
||||
newest []time.Time
|
||||
byTarget []struct{ TargetID string }
|
||||
)
|
||||
|
||||
@@ -197,6 +200,12 @@ func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
|
||||
Where("created_at >= ?", since).Count(&count),
|
||||
"idx_events_deleted_at_created_at "+
|
||||
"(deleted_at=? AND created_at>?)")
|
||||
|
||||
newestEvent := dry.Model(&database.Event{}).
|
||||
Order("created_at DESC").Limit(1).Pluck("created_at", &newest)
|
||||
assertPlanUses(t, db, newestEvent,
|
||||
"idx_events_deleted_at_created_at (deleted_at=?)")
|
||||
assert.NotContains(t, queryPlan(t, db, newestEvent), "TEMP B-TREE")
|
||||
}
|
||||
|
||||
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
||||
@@ -207,6 +216,18 @@ func assertPlanUses(
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
plan := queryPlan(t, db, built)
|
||||
|
||||
for _, index := range indexes {
|
||||
assert.Contains(t, plan, index, built.Statement.SQL.String())
|
||||
}
|
||||
}
|
||||
|
||||
// queryPlan returns SQLite's plan for a statement GORM built in a dry
|
||||
// run, run with the same SQL and arguments GORM would send.
|
||||
func queryPlan(t *testing.T, db, built *gorm.DB) string {
|
||||
t.Helper()
|
||||
|
||||
var plan []struct{ Detail string }
|
||||
|
||||
require.NoError(t, db.Raw(
|
||||
@@ -214,8 +235,5 @@ func assertPlanUses(
|
||||
built.Statement.Vars...,
|
||||
).Scan(&plan).Error)
|
||||
|
||||
for _, index := range indexes {
|
||||
assert.Contains(t, fmt.Sprint(plan), index,
|
||||
built.Statement.SQL.String())
|
||||
}
|
||||
return fmt.Sprint(plan)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
@@ -84,14 +83,3 @@ func (d *Database) ExportSetBannerOut(w io.Writer) {
|
||||
func DummyPasswordHashForTest() string {
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -31,11 +31,6 @@ type Event struct {
|
||||
Body string `gorm:"type:text" json:"body"`
|
||||
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
|
||||
// an operator resubmit. It is nil for an event that arrived on
|
||||
// the receiver, which is every event created before the column
|
||||
|
||||
@@ -2,7 +2,6 @@ package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -14,17 +13,12 @@ import (
|
||||
// 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.
|
||||
// event ever stored, and how many of them retention has deleted.
|
||||
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.
|
||||
@@ -52,17 +46,15 @@ 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
|
||||
// AddEventTotals adds each count in add to the webhook's event totals.
|
||||
// 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,
|
||||
events_removed = events_removed + ?`,
|
||||
add.Events, add.EventsRemoved,
|
||||
).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding to event totals: %w", err)
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
@@ -64,30 +63,10 @@ func DefaultPasswordConfig() *PasswordConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// testArgon2Memory is the Argon2id memory cost, in KiB, that a test
|
||||
// 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.
|
||||
// HashPassword generates an Argon2id hash of the password
|
||||
func HashPassword(password string) (string, error) {
|
||||
config := DefaultPasswordConfig()
|
||||
|
||||
if testing.Testing() && !hashAtShippedCostInTest {
|
||||
config.Memory = testArgon2Memory
|
||||
}
|
||||
|
||||
// Generate a salt
|
||||
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
|
||||
// path. Login charges an unknown username a verification against a
|
||||
// dummy hash so that a nonexistent account is not answered in
|
||||
|
||||
@@ -25,12 +25,6 @@ const hoursPerDay = 24
|
||||
// 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
|
||||
// RetentionReaper.
|
||||
type RetentionReaperParams struct {
|
||||
@@ -200,15 +194,13 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
r.reapWebhook(ctx, wh.ID, wh.RetentionDays)
|
||||
r.reapWebhook(wh.ID, wh.RetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
// reapWebhook removes every expired event (and its dependents) from a
|
||||
// single webhook's database, or as many as it reaches before ctx is
|
||||
// cancelled.
|
||||
// single webhook's database.
|
||||
func (r *RetentionReaper) reapWebhook(
|
||||
ctx context.Context,
|
||||
webhookID string,
|
||||
retentionDays int,
|
||||
) {
|
||||
@@ -228,7 +220,7 @@ func (r *RetentionReaper) reapWebhook(
|
||||
return
|
||||
}
|
||||
|
||||
deleted, err := reapExpired(ctx, db, cutoff)
|
||||
deleted, err := reapExpired(db, cutoff)
|
||||
if err != nil {
|
||||
r.log.Error(
|
||||
"retention sweep: failed to reap expired events",
|
||||
@@ -282,13 +274,9 @@ func retentionCutoff(
|
||||
|
||||
// reapExpired hard-deletes the events older than cutoff, with their
|
||||
// deliveries and delivery results, reapBatchSize events per
|
||||
// transaction with reapBatchPause between transactions, until none is
|
||||
// left. Once ctx is cancelled it returns after the batch in hand,
|
||||
// leaving the rest to the next sweep, so stopping the app does not
|
||||
// wait for a long prune. It returns the number of events deleted.
|
||||
func reapExpired(
|
||||
ctx context.Context, db *gorm.DB, cutoff time.Time,
|
||||
) (int64, error) {
|
||||
// transaction until none is left. It returns the number of events
|
||||
// deleted.
|
||||
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
|
||||
var total int64
|
||||
|
||||
for {
|
||||
@@ -318,12 +306,6 @@ func reapExpired(
|
||||
if len(eventIDs) < reapBatchSize {
|
||||
return total, nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return total, nil
|
||||
case <-time.After(reapBatchPause):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,21 +99,29 @@ func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
|
||||
}, 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()
|
||||
// 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()
|
||||
|
||||
events := make([]database.Event, count)
|
||||
deliveries := make([]database.Delivery, 0, 2*count)
|
||||
env := setupRetentionTest(t)
|
||||
|
||||
webhookID := createWebhook(t, env.mainDB.DB(), 30)
|
||||
|
||||
db, err := env.mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Every expired event has a delivered delivery to one target and a
|
||||
// failed one to the other, each with one attempt.
|
||||
expired := database.ExportReapBatchSize + 1
|
||||
delivered, failed := uuid.New().String(), uuid.New().String()
|
||||
old := time.Now().Add(-40 * 24 * time.Hour)
|
||||
|
||||
events := make([]database.Event, expired)
|
||||
deliveries := make([]database.Delivery, 0, 2*expired)
|
||||
|
||||
for i := range events {
|
||||
events[i] = database.Event{
|
||||
@@ -122,7 +130,7 @@ func seedExpiredEvents(
|
||||
Method: http.MethodPost,
|
||||
}
|
||||
events[i].ID = uuid.New().String()
|
||||
events[i].CreatedAt = createdAt
|
||||
events[i].CreatedAt = old
|
||||
|
||||
deliveries = append(deliveries,
|
||||
database.Delivery{
|
||||
@@ -149,51 +157,6 @@ func seedExpiredEvents(
|
||||
}
|
||||
|
||||
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())
|
||||
@@ -250,161 +213,3 @@ func TestRetentionReaper_PrunesMoreThanOneBatch(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,64 +114,3 @@ func TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted(t *testing.T) {
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,11 +531,6 @@ func (e *Engine) processRetryTask(
|
||||
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 {
|
||||
e.log.Debug(
|
||||
"skipping retry for delivery "+
|
||||
@@ -567,6 +562,8 @@ func (e *Engine) processRetryTask(
|
||||
}
|
||||
|
||||
target := buildTargetFromTask(task)
|
||||
d.EventID = task.EventID
|
||||
d.TargetID = task.TargetID
|
||||
d.Event = event
|
||||
d.Target = target
|
||||
|
||||
@@ -1580,11 +1577,9 @@ func (e *Engine) updateDeliveryStatus(
|
||||
|
||||
// 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.
|
||||
// added to its target's delivered or failed total. It is counted only
|
||||
// if the row was still there to update: retention may have deleted it
|
||||
// while the engine was working on it.
|
||||
func writeDeliveryStatus(
|
||||
tx *gorm.DB,
|
||||
d *database.Delivery,
|
||||
@@ -1594,15 +1589,10 @@ func writeDeliveryStatus(
|
||||
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(),
|
||||
})
|
||||
res := tx.Model(d).Updates(map[string]any{
|
||||
"status": status,
|
||||
"finished_at": time.Now(),
|
||||
})
|
||||
if res.Error != nil || res.RowsAffected == 0 {
|
||||
return res.Error
|
||||
}
|
||||
|
||||
@@ -43,13 +43,6 @@ var (
|
||||
// permit specific blocks out of this set with
|
||||
// ALLOWED_EGRESS_CIDRS; see Guard.
|
||||
//
|
||||
// A public address belongs on the default blocklist only if it
|
||||
// hands credentials, user data or bootstrap material to whatever
|
||||
// can reach it, without the caller presenting anything. A
|
||||
// provider's other public addresses are not refused, since
|
||||
// reaching them can be legitimate and no list of them could be
|
||||
// complete.
|
||||
//
|
||||
//nolint:gochecknoglobals // package-level network list is appropriate here
|
||||
var blockedNetworks []*net.IPNet
|
||||
|
||||
|
||||
@@ -418,38 +418,6 @@ func TestProcessRetryTask_TargetDeleted_MakesNoAttempt(
|
||||
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
|
||||
// mutation check: a liveness check that refused every retry would pass
|
||||
// the test above and break every retry there is.
|
||||
|
||||
@@ -103,10 +103,9 @@ func (h *Handlers) renderLoginError(
|
||||
// The credential check runs BEFORE any rate-limit budget is
|
||||
// consulted, and only a failed check spends budget. That is what
|
||||
// keeps the single administrative path reachable: behind the reverse
|
||||
// proxy this deployment requires, when TRUSTED_PROXIES does not cover
|
||||
// it, every client shares one bucket, so a limiter spent on arrival
|
||||
// lets any stranger deny the operator's own correct password
|
||||
// indefinitely.
|
||||
// proxy this deployment requires, with TRUSTED_PROXIES unset, every
|
||||
// client shares one bucket, so a limiter spent on arrival lets any
|
||||
// stranger deny the operator's own correct password indefinitely.
|
||||
//
|
||||
// Verifying first means every login POST costs an Argon2id hash, so
|
||||
// the work is taken under a bounded number of verification slots.
|
||||
|
||||
@@ -25,7 +25,7 @@ const (
|
||||
|
||||
// sharedProxyPeer is the whole point of this file. Production is
|
||||
// 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
|
||||
// address and shares one rate-limit bucket. Both parties in
|
||||
// these tests therefore use the same RemoteAddr.
|
||||
@@ -115,11 +115,11 @@ func floodFailures(
|
||||
// done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150.
|
||||
//
|
||||
// The attacker and the operator share one rate-limit bucket, because
|
||||
// behind the mandated reverse proxy, when TRUSTED_PROXIES does not
|
||||
// cover it, every client keys on the proxy's address. The attacker
|
||||
// floods the operator's own username — a single-admin product has a
|
||||
// predictable one — far past the failure limit. The operator must
|
||||
// still be able to log in with the correct password.
|
||||
// behind the mandated reverse proxy with TRUSTED_PROXIES unset every
|
||||
// client keys on the proxy's address. The attacker floods the
|
||||
// operator's own username — a single-admin product has a predictable
|
||||
// one — far past the failure limit. The operator must still be able
|
||||
// to log in with the correct password.
|
||||
//
|
||||
// This fails if credentials stop being verified ahead of the limiter.
|
||||
func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) {
|
||||
|
||||
@@ -204,7 +204,6 @@ func assertEventCopy(
|
||||
assert.Equal(t, original.Method, fresh.Method)
|
||||
assert.Equal(t, original.Headers, fresh.Headers)
|
||||
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.EntrypointID, fresh.EntrypointID)
|
||||
assert.Equal(t, original.WebhookID, fresh.WebhookID)
|
||||
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
// maxBodyShift is the bit shift for 1 MB body limit.
|
||||
maxBodyShift = 20
|
||||
// recentEventLimit is the number of recent events to show.
|
||||
recentEventLimit = 50
|
||||
recentEventLimit = 20
|
||||
// paginationPerPage is the number of items per page.
|
||||
paginationPerPage = 25
|
||||
|
||||
|
||||
@@ -88,8 +88,6 @@ func TestHandleProfile_OwnProfile_OK(t *testing.T) {
|
||||
h.HandleProfile().ServeHTTP(w, req)
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// recentEventColumns is the recent events list's projection. It
|
||||
// leaves out the body, for the reason maxRenderedBodyBytes gives,
|
||||
// and reads its size from body_bytes, recorded when the event was
|
||||
// stored.
|
||||
const recentEventColumns = "id, created_at, method, content_type, " +
|
||||
"resubmitted_from_id, body_bytes"
|
||||
|
||||
// recentAttemptColumns is the part of a recorded attempt the list
|
||||
// uses. The event log's deliveryResultColumns also reads response
|
||||
// bodies, which the list does not show.
|
||||
const recentAttemptColumns = "delivery_id, status_code, created_at"
|
||||
|
||||
// RecentEventView is one row of the recent events list on a
|
||||
// webhook's page.
|
||||
type RecentEventView struct {
|
||||
Method string
|
||||
ContentType string
|
||||
|
||||
// ResubmittedFromID names the event this one was copied from,
|
||||
// empty for an event that arrived on the receiver.
|
||||
ResubmittedFromID string
|
||||
|
||||
// Received is how long ago the event arrived, and ReceivedUTC
|
||||
// the full timestamp the page shows on hover.
|
||||
Received string
|
||||
ReceivedUTC string
|
||||
|
||||
// Size is the size of the stored body.
|
||||
Size string
|
||||
|
||||
// ProcessingTime is how long the event's slowest delivery
|
||||
// took; see processingTime.
|
||||
ProcessingTime string
|
||||
|
||||
// Status is what the webhook's HTTP target answered, and
|
||||
// StatusClass its colour; see targetStatus. Both are empty
|
||||
// unless the webhook has exactly one HTTP target.
|
||||
Status string
|
||||
StatusClass string
|
||||
}
|
||||
|
||||
// recentEventRow is one row of recentEventColumns.
|
||||
type recentEventRow struct {
|
||||
ID string
|
||||
CreatedAt time.Time
|
||||
Method string
|
||||
ContentType string
|
||||
ResubmittedFromID *string
|
||||
BodyBytes uint64
|
||||
}
|
||||
|
||||
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
|
||||
// when the attempt's result was recorded, which is when the attempt
|
||||
// finished.
|
||||
type recentAttemptRow struct {
|
||||
DeliveryID string
|
||||
StatusCode int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// singleHTTPTargetID returns the ID of the webhook's HTTP target
|
||||
// when it has exactly one, and "" when it has none or several.
|
||||
func singleHTTPTargetID(targets []database.Target) string {
|
||||
id := ""
|
||||
count := 0
|
||||
|
||||
for i := range targets {
|
||||
if targets[i].Type == database.TargetTypeHTTP {
|
||||
id = targets[i].ID
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// loadRecentEvents loads the webhook's recentEventLimit newest
|
||||
// events for its page, newest first. statusTargetID is the
|
||||
// webhook's only HTTP target, or "" when the list shows no status.
|
||||
func loadRecentEvents(
|
||||
webhookDB *gorm.DB, webhookID, statusTargetID string,
|
||||
) ([]RecentEventView, error) {
|
||||
var rows []recentEventRow
|
||||
|
||||
err := webhookDB.Model(&database.Event{}).
|
||||
Select(recentEventColumns).
|
||||
Where("webhook_id = ?", webhookID).
|
||||
Order("created_at DESC").
|
||||
Limit(recentEventLimit).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
eventIDs := make([]string, len(rows))
|
||||
for i := range rows {
|
||||
eventIDs[i] = rows[i].ID
|
||||
}
|
||||
|
||||
// Oldest first, so an event's last delivery to a target is its
|
||||
// newest: a replay adds a delivery rather than changing the
|
||||
// earlier one.
|
||||
var deliveries []database.Delivery
|
||||
|
||||
err = webhookDB.
|
||||
Select("id, event_id, target_id, status, created_at").
|
||||
Where("event_id IN ?", eventIDs).
|
||||
Order("created_at ASC").
|
||||
Find(&deliveries).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byEvent := make(map[string][]database.Delivery, len(rows))
|
||||
deliveryIDs := make([]string, len(deliveries))
|
||||
|
||||
for i := range deliveries {
|
||||
eventID := deliveries[i].EventID
|
||||
byEvent[eventID] = append(byEvent[eventID], deliveries[i])
|
||||
deliveryIDs[i] = deliveries[i].ID
|
||||
}
|
||||
|
||||
attempts, err := loadRecentAttempts(webhookDB, deliveryIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
views := make([]RecentEventView, len(rows))
|
||||
for i := range rows {
|
||||
views[i] = rows[i].view(
|
||||
byEvent[rows[i].ID], attempts, statusTargetID,
|
||||
)
|
||||
}
|
||||
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// loadRecentAttempts loads the recorded attempts of the listed
|
||||
// events' deliveries, keyed by delivery ID, each delivery's in
|
||||
// attempt order. The IDs go in chunks for the reason
|
||||
// deliveryIDChunkSize gives.
|
||||
func loadRecentAttempts(
|
||||
webhookDB *gorm.DB, deliveryIDs []string,
|
||||
) (map[string][]recentAttemptRow, error) {
|
||||
byDelivery := make(map[string][]recentAttemptRow)
|
||||
|
||||
for chunk := range slices.Chunk(deliveryIDs, deliveryIDChunkSize) {
|
||||
var rows []recentAttemptRow
|
||||
|
||||
err := webhookDB.Model(&database.DeliveryResult{}).
|
||||
Select(recentAttemptColumns).
|
||||
Where("delivery_id IN ?", chunk).
|
||||
Order("attempt_num ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range rows {
|
||||
id := rows[i].DeliveryID
|
||||
byDelivery[id] = append(byDelivery[id], rows[i])
|
||||
}
|
||||
}
|
||||
|
||||
return byDelivery, nil
|
||||
}
|
||||
|
||||
// view projects a loaded row for rendering. deliveries is the
|
||||
// event's deliveries, oldest first, and attempts their recorded
|
||||
// attempts keyed by delivery ID.
|
||||
func (r *recentEventRow) view(
|
||||
deliveries []database.Delivery,
|
||||
attempts map[string][]recentAttemptRow,
|
||||
statusTargetID string,
|
||||
) RecentEventView {
|
||||
v := RecentEventView{
|
||||
Method: r.Method,
|
||||
ContentType: r.ContentType,
|
||||
Received: humanize.Time(r.CreatedAt),
|
||||
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
||||
Size: humanize.Bytes(r.BodyBytes),
|
||||
ProcessingTime: processingTime(deliveries, attempts),
|
||||
}
|
||||
|
||||
if r.ResubmittedFromID != nil {
|
||||
v.ResubmittedFromID = *r.ResubmittedFromID
|
||||
}
|
||||
|
||||
if statusTargetID != "" {
|
||||
v.Status, v.StatusClass = targetStatus(
|
||||
deliveries, attempts, statusTargetID,
|
||||
)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// processingTime is how long the event's slowest delivery took,
|
||||
// from being queued to its last recorded attempt, time spent
|
||||
// waiting between retries included. A delivery is queued when its
|
||||
// event is received, or when an operator replays it, so a replay
|
||||
// is timed from the replay rather than from the event's arrival.
|
||||
// It is "in progress" while any delivery is pending or retrying,
|
||||
// and empty for an event with no deliveries.
|
||||
func processingTime(
|
||||
deliveries []database.Delivery,
|
||||
attempts map[string][]recentAttemptRow,
|
||||
) string {
|
||||
if len(deliveries) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var slowest time.Duration
|
||||
|
||||
for i := range deliveries {
|
||||
if !deliveries[i].Status.Terminal() {
|
||||
return "in progress"
|
||||
}
|
||||
|
||||
tries := attempts[deliveries[i].ID]
|
||||
if len(tries) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
last := tries[len(tries)-1].CreatedAt
|
||||
slowest = max(slowest, last.Sub(deliveries[i].CreatedAt))
|
||||
}
|
||||
|
||||
return slowest.Round(time.Millisecond).String()
|
||||
}
|
||||
|
||||
// targetStatus is what the target answered for the event, and the
|
||||
// colour to show it in: the HTTP status code of the last attempt of
|
||||
// the event's newest delivery to the target. Without a code it is
|
||||
// "no response" when that attempt failed before a response
|
||||
// arrived, the delivery's status ("pending") before any attempt,
|
||||
// and "not sent" when the event has no delivery to the target.
|
||||
func targetStatus(
|
||||
deliveries []database.Delivery,
|
||||
attempts map[string][]recentAttemptRow,
|
||||
targetID string,
|
||||
) (string, string) {
|
||||
newest := -1
|
||||
|
||||
for i := range deliveries {
|
||||
if deliveries[i].TargetID == targetID {
|
||||
newest = i
|
||||
}
|
||||
}
|
||||
|
||||
if newest < 0 {
|
||||
return "not sent", "text-gray-400"
|
||||
}
|
||||
|
||||
tries := attempts[deliveries[newest].ID]
|
||||
if len(tries) == 0 {
|
||||
return string(deliveries[newest].Status), "text-gray-400"
|
||||
}
|
||||
|
||||
code := tries[len(tries)-1].StatusCode
|
||||
|
||||
switch {
|
||||
case code == 0:
|
||||
return "no response", "text-red-600"
|
||||
case code >= http.StatusInternalServerError:
|
||||
return strconv.Itoa(code), "text-red-600"
|
||||
case code >= http.StatusBadRequest:
|
||||
return strconv.Itoa(code), "text-yellow-600"
|
||||
case code >= http.StatusMultipleChoices:
|
||||
return strconv.Itoa(code), "text-gray-500"
|
||||
case code >= http.StatusOK:
|
||||
return strconv.Itoa(code), "text-green-600"
|
||||
default:
|
||||
return strconv.Itoa(code), "text-gray-500"
|
||||
}
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// statusTitle marks the status column's cell in a recent events
|
||||
// row; it is absent from the page when the column is not shown.
|
||||
const statusTitle = `title="HTTP status from the HTTP target"`
|
||||
|
||||
// recentEventsFixture is one started app and a webhook whose
|
||||
// recent events list a test fills.
|
||||
type recentEventsFixture struct {
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
webhook *database.Webhook
|
||||
webhookDB *gorm.DB
|
||||
}
|
||||
|
||||
func newRecentEventsFixture(t *testing.T) *recentEventsFixture {
|
||||
t.Helper()
|
||||
|
||||
f := &recentEventsFixture{}
|
||||
|
||||
var dbMgr *database.WebhookDBManager
|
||||
|
||||
app := newTestApp(t, &f.h, &f.sess, &f.db, &dbMgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
f.webhook = seedWebhook(t, f.db)
|
||||
|
||||
webhookDB, err := dbMgr.GetDB(f.webhook.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
f.webhookDB = webhookDB
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *recentEventsFixture) render(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return renderSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
|
||||
}
|
||||
|
||||
// event records an event received at receivedAt, with its body's
|
||||
// size as the receiver records it.
|
||||
func (f *recentEventsFixture) event(
|
||||
t *testing.T, contentType, body string, receivedAt time.Time,
|
||||
) *database.Event {
|
||||
t.Helper()
|
||||
|
||||
event := &database.Event{
|
||||
WebhookID: f.webhook.ID,
|
||||
Method: http.MethodPost,
|
||||
Body: body,
|
||||
BodyBytes: int64(len(body)),
|
||||
ContentType: contentType,
|
||||
}
|
||||
event.CreatedAt = receivedAt
|
||||
|
||||
require.NoError(t, f.webhookDB.Omit(
|
||||
clause.Associations,
|
||||
).Create(event).Error)
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// delivery records a delivery of the event to the target, queued
|
||||
// when the event was received.
|
||||
func (f *recentEventsFixture) delivery(
|
||||
t *testing.T,
|
||||
event *database.Event,
|
||||
targetID string,
|
||||
status database.DeliveryStatus,
|
||||
) *database.Delivery {
|
||||
t.Helper()
|
||||
|
||||
return f.deliveryQueuedAt(
|
||||
t, event, targetID, status, event.CreatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
// deliveryQueuedAt records a delivery of the event to the target,
|
||||
// queued at queuedAt, as a replay is.
|
||||
func (f *recentEventsFixture) deliveryQueuedAt(
|
||||
t *testing.T,
|
||||
event *database.Event,
|
||||
targetID string,
|
||||
status database.DeliveryStatus,
|
||||
queuedAt time.Time,
|
||||
) *database.Delivery {
|
||||
t.Helper()
|
||||
|
||||
dlv := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
TargetID: targetID,
|
||||
Status: status,
|
||||
}
|
||||
dlv.CreatedAt = queuedAt
|
||||
|
||||
require.NoError(t, f.webhookDB.Omit(
|
||||
clause.Associations,
|
||||
).Create(dlv).Error)
|
||||
|
||||
return dlv
|
||||
}
|
||||
|
||||
// attempt records one attempt of the delivery that finished took
|
||||
// after the delivery was queued, with HTTP status code (0 for no
|
||||
// response).
|
||||
func (f *recentEventsFixture) attempt(
|
||||
t *testing.T, dlv *database.Delivery, code int, took time.Duration,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
result := &database.DeliveryResult{
|
||||
DeliveryID: dlv.ID,
|
||||
AttemptNum: 1,
|
||||
StatusCode: code,
|
||||
}
|
||||
result.CreatedAt = dlv.CreatedAt.Add(took)
|
||||
|
||||
require.NoError(t, f.webhookDB.Omit(
|
||||
clause.Associations,
|
||||
).Create(result).Error)
|
||||
}
|
||||
|
||||
// statusCell is the status column's cell as the page renders it.
|
||||
func statusCell(class, text string) string {
|
||||
return `<span class="font-medium ` + class + `" ` + statusTitle +
|
||||
`>` + text + `</span>`
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_ShowsFiftyNewestEvents proves the list is
|
||||
// headed "50 Most Recent Events" and holds the 50 newest events,
|
||||
// newest first, and not one more.
|
||||
func TestHandleSourceDetail_ShowsFiftyNewestEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
base := time.Now().Add(-time.Hour)
|
||||
|
||||
for i := range 51 {
|
||||
f.event(
|
||||
t, fmt.Sprintf("application/x-recent-%02d", i), "{}",
|
||||
base.Add(time.Duration(i)*time.Second),
|
||||
)
|
||||
}
|
||||
|
||||
body := f.render(t)
|
||||
|
||||
assert.Contains(t, body, ">50 Most Recent Events</h2>")
|
||||
assert.Equal(t, 50, strings.Count(body, `title="Body size"`))
|
||||
assert.NotContains(t, body, "application/x-recent-00")
|
||||
assert.Contains(t, body, "application/x-recent-01")
|
||||
assert.Less(
|
||||
t,
|
||||
strings.Index(body, "application/x-recent-50"),
|
||||
strings.Index(body, "application/x-recent-49"),
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_RecentEventColumns proves a row shows its
|
||||
// time relative with the UTC timestamp on hover, its body size,
|
||||
// and its processing time once every delivery has finished.
|
||||
func TestHandleSourceDetail_RecentEventColumns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
logTarget := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
||||
|
||||
receivedAt := time.Now().Add(-210 * time.Second).
|
||||
UTC().Truncate(time.Second)
|
||||
|
||||
done := f.event(
|
||||
t, contentTypeJSON, strings.Repeat("x", 2048), receivedAt,
|
||||
)
|
||||
f.attempt(
|
||||
t,
|
||||
f.delivery(t, done, logTarget.ID, database.DeliveryStatusDelivered),
|
||||
0, 1500*time.Millisecond,
|
||||
)
|
||||
|
||||
waiting := f.event(t, "text/plain", "{}", receivedAt)
|
||||
f.delivery(t, waiting, logTarget.ID, database.DeliveryStatusPending)
|
||||
|
||||
body := f.render(t)
|
||||
|
||||
assert.Contains(
|
||||
t, body,
|
||||
`<span title="`+receivedAt.Format(time.DateTime)+
|
||||
` UTC">3 minutes ago</span>`,
|
||||
)
|
||||
assert.Contains(t, body, `<span title="Body size">2.0 kB</span>`)
|
||||
assert.Contains(t, body, ">1.5s</span>")
|
||||
assert.Contains(t, body, ">in progress</span>")
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_StatusWithSingleHTTPTarget proves that a
|
||||
// webhook with exactly one HTTP target shows, colour-coded, what
|
||||
// that target answered for each event. The log target beside it
|
||||
// does not count against "exactly one".
|
||||
func TestHandleSourceDetail_StatusWithSingleHTTPTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
||||
seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for _, code := range []int{204, 302, 404, 503, 0} {
|
||||
dlv := f.delivery(
|
||||
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
|
||||
database.DeliveryStatusDelivered,
|
||||
)
|
||||
f.attempt(t, dlv, code, time.Second)
|
||||
}
|
||||
|
||||
f.delivery(
|
||||
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
f.event(t, contentTypeJSON, "{}", now)
|
||||
|
||||
// A replay is a newer delivery, and its answer is the one shown.
|
||||
replayed := f.event(t, contentTypeJSON, "{}", now)
|
||||
f.attempt(t, f.delivery(
|
||||
t, replayed, target.ID, database.DeliveryStatusFailed,
|
||||
), 502, time.Second)
|
||||
f.attempt(t, f.deliveryQueuedAt(
|
||||
t, replayed, target.ID, database.DeliveryStatusDelivered,
|
||||
now.Add(time.Minute),
|
||||
), 200, time.Second)
|
||||
|
||||
body := f.render(t)
|
||||
|
||||
assert.Contains(t, body, statusCell("text-green-600", "204"))
|
||||
assert.Contains(t, body, statusCell("text-gray-500", "302"))
|
||||
assert.Contains(t, body, statusCell("text-yellow-600", "404"))
|
||||
assert.Contains(t, body, statusCell("text-red-600", "503"))
|
||||
assert.Contains(t, body, statusCell("text-red-600", "no response"))
|
||||
assert.Contains(t, body, statusCell("text-gray-400", "pending"))
|
||||
assert.Contains(t, body, statusCell("text-gray-400", "not sent"))
|
||||
assert.Contains(t, body, statusCell("text-green-600", "200"))
|
||||
assert.NotContains(t, body, ">502<")
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget proves the
|
||||
// status column is absent when the webhook has no HTTP target or
|
||||
// more than one.
|
||||
func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string][]database.TargetType{
|
||||
"none": {database.TargetTypeLog},
|
||||
"several": {database.TargetTypeHTTP, database.TargetTypeHTTP},
|
||||
}
|
||||
|
||||
for name, types := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
event := f.event(t, contentTypeJSON, "{}", time.Now())
|
||||
|
||||
for _, tt := range types {
|
||||
target := seedTarget(t, f.db, f.webhook.ID, tt)
|
||||
f.attempt(t, f.delivery(
|
||||
t, event, target.ID,
|
||||
database.DeliveryStatusDelivered,
|
||||
), 200, time.Second)
|
||||
}
|
||||
|
||||
body := f.render(t)
|
||||
|
||||
assert.Contains(t, body, `title="Body size"`)
|
||||
assert.NotContains(t, body, statusTitle)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleWebhook_RecordsBodySize proves the receiver records the
|
||||
// body's size in bytes, not characters, with the event it stores.
|
||||
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
seedEntrypoint(t, f.db, f.webhook.ID)
|
||||
|
||||
// Two bytes per character.
|
||||
body := strings.Repeat("é", 1024)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodPost, "/webhook/x",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("uuid", "ep-"+f.webhook.ID)
|
||||
|
||||
req = req.WithContext(context.WithValue(
|
||||
req.Context(), chi.RouteCtxKey, rctx,
|
||||
))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
f.h.HandleWebhook().ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var stored database.Event
|
||||
|
||||
require.NoError(t, f.webhookDB.First(&stored).Error)
|
||||
assert.Equal(t, int64(2048), stored.BodyBytes)
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_FailedLoadIsAnError proves that when the
|
||||
// list cannot be loaded the page answers with an error, rather than
|
||||
// an empty list claiming the webhook has no events.
|
||||
func TestHandleSourceDetail_FailedLoadIsAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
||||
|
||||
f.attempt(t, f.delivery(
|
||||
t, f.event(t, contentTypeJSON, "{}", time.Now()), target.ID,
|
||||
database.DeliveryStatusDelivered,
|
||||
), 200, time.Second)
|
||||
|
||||
// The attempts are the list's last query, so its events and
|
||||
// deliveries have already loaded when it fails.
|
||||
require.NoError(t, f.webhookDB.Exec(
|
||||
"DROP TABLE delivery_results",
|
||||
).Error)
|
||||
|
||||
w := serveSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.NotContains(t, w.Body.String(), "No events received yet.")
|
||||
}
|
||||
@@ -62,23 +62,6 @@ func renderSourceDetailPage(
|
||||
) string {
|
||||
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(
|
||||
context.Background(),
|
||||
http.MethodGet,
|
||||
@@ -104,7 +87,9 @@ func serveSourceDetailPage(
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleSourceDetail().ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
return w.Body.String()
|
||||
}
|
||||
|
||||
// TestHandleSourceDetail_MasksSlackWebhookURL is the
|
||||
|
||||
@@ -415,23 +415,16 @@ func (h *Handlers) renderSourceDetail(
|
||||
"webhook_id = ?", webhook.ID,
|
||||
).Find(&targets)
|
||||
|
||||
var events []RecentEventView
|
||||
var events []database.Event
|
||||
|
||||
if h.dbMgr.DBExists(webhook.ID) {
|
||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to get webhook database", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
events, err = loadRecentEvents(
|
||||
webhookDB, webhook.ID, singleHTTPTargetID(targets),
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to load recent events", err)
|
||||
|
||||
return
|
||||
webhookDB, dbErr := h.dbMgr.GetDB(webhook.ID)
|
||||
if dbErr == nil {
|
||||
webhookDB.Where(
|
||||
"webhook_id = ?", webhook.ID,
|
||||
).Order("created_at DESC").Limit(
|
||||
recentEventLimit,
|
||||
).Find(&events)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -230,7 +230,6 @@ func (s eventSource) event() *database.Event {
|
||||
Method: s.Method,
|
||||
Headers: s.HeadersJSON,
|
||||
Body: string(s.Body),
|
||||
BodyBytes: int64(len(s.Body)),
|
||||
ContentType: s.ContentType,
|
||||
ResubmittedFromID: s.ResubmittedFromID,
|
||||
}
|
||||
@@ -298,9 +297,7 @@ func (h *Handlers) createAndFanOut(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
err = database.AddEventTotals(tx, database.EventTotals{
|
||||
Events: 1, LastEventAt: &event.CreatedAt,
|
||||
})
|
||||
err = database.AddEventTotals(tx, database.EventTotals{Events: 1})
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ type WebhookStats struct {
|
||||
// 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 is when the newest stored event arrived, or nil when
|
||||
// none is stored.
|
||||
LastEventAt *time.Time
|
||||
|
||||
Last10Minutes RecentWindow
|
||||
@@ -154,6 +154,20 @@ func readEventStats(
|
||||
return fmt.Errorf("counting deliveries in progress: %w", err)
|
||||
}
|
||||
|
||||
var newest []time.Time
|
||||
|
||||
err = db.Model(&database.Event{}).
|
||||
Order("created_at DESC").
|
||||
Limit(1).
|
||||
Pluck("created_at", &newest).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading newest event time: %w", err)
|
||||
}
|
||||
|
||||
if len(newest) > 0 {
|
||||
stats.LastEventAt = &newest[0]
|
||||
}
|
||||
|
||||
stats.Last10Minutes, err = readRecentWindow(
|
||||
db, now.Add(-shortWindow),
|
||||
)
|
||||
@@ -168,9 +182,8 @@ func readEventStats(
|
||||
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.
|
||||
// readTotals fills in the lifetime and within-retention figures from
|
||||
// the running totals: the events' row, and the targets' rows summed.
|
||||
func readTotals(db *gorm.DB, stats *WebhookStats) error {
|
||||
var events database.EventTotals
|
||||
|
||||
@@ -188,7 +201,6 @@ func readTotals(db *gorm.DB, stats *WebhookStats) error {
|
||||
|
||||
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
|
||||
|
||||
@@ -2,7 +2,6 @@ package handlers_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -121,15 +120,13 @@ type statsHistory struct {
|
||||
first, second string
|
||||
}
|
||||
|
||||
// seedStatsHistory builds the webhook the statistics test checks: 14
|
||||
// days of retention, eleven entrypoints (one inactive) and five targets
|
||||
// (three inactive). Nine events arrive through the receiver, and so
|
||||
// seedStatsHistory builds the webhook the statistics test checks: one
|
||||
// day of retention, two entrypoints (one inactive) and three targets
|
||||
// (one inactive). Three events arrive through the receiver, and so
|
||||
// each has a delivery to the two active targets. The oldest event is
|
||||
// past retention, the next 30 hours old, the next six hours old, the
|
||||
// other six just in. Five deliveries are settled as the delivery engine
|
||||
// would, the rest 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.
|
||||
// past retention, the middle one six hours old, the newest just in.
|
||||
// Their deliveries are settled as the delivery engine would, and a
|
||||
// replay adds a pending delivery to the oldest event.
|
||||
func seedStatsHistory(
|
||||
t *testing.T,
|
||||
h *handlers.Handlers,
|
||||
@@ -140,15 +137,11 @@ func seedStatsHistory(
|
||||
t.Helper()
|
||||
|
||||
wh := &database.Webhook{
|
||||
UserID: deleteTestUserID, Name: "stats", RetentionDays: 14,
|
||||
UserID: deleteTestUserID, Name: "stats", RetentionDays: 1,
|
||||
}
|
||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||
|
||||
path := statsEntrypoint(t, db, wh.ID, true)
|
||||
for range 9 {
|
||||
statsEntrypoint(t, db, wh.ID, true)
|
||||
}
|
||||
|
||||
statsEntrypoint(t, db, wh.ID, false)
|
||||
|
||||
first := seedConfiguredTarget(
|
||||
@@ -156,16 +149,13 @@ func seedStatsHistory(
|
||||
`{"url":"`+replayTargetURL+`"}`,
|
||||
)
|
||||
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||
|
||||
for range 3 {
|
||||
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||
require.NoError(t, db.DB().Model(inactive).
|
||||
Update("active", false).Error)
|
||||
}
|
||||
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||
require.NoError(t, db.DB().Model(inactive).
|
||||
Update("active", false).Error)
|
||||
|
||||
router := receiverRouter(h)
|
||||
|
||||
for range 9 {
|
||||
for range 3 {
|
||||
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
|
||||
}
|
||||
|
||||
@@ -173,27 +163,26 @@ func seedStatsHistory(
|
||||
require.NoError(t, err)
|
||||
|
||||
events := listEvents(t, webhookDB)
|
||||
require.Len(t, events, 9)
|
||||
require.Len(t, events, 3)
|
||||
|
||||
oldest, yesterday, middle, newest := events[0], events[1], events[2], events[8]
|
||||
oldest, middle, newest := events[0], events[1], events[2]
|
||||
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, oldest.ID, now.Add(-50*time.Hour))
|
||||
statsAge(t, webhookDB, middle.ID, now.Add(-6*time.Hour))
|
||||
|
||||
oldestFailure := statsDelivery(t, webhookDB, oldest.ID, first.ID)
|
||||
statsFinish(t, webhookDB, oldestFailure,
|
||||
database.DeliveryStatusFailed, now.Add(-14*24*time.Hour))
|
||||
database.DeliveryStatusFailed, now.Add(-49*time.Hour))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, yesterday.ID, first.ID),
|
||||
database.DeliveryStatusFailed, now.Add(-29*time.Hour))
|
||||
statsDelivery(t, webhookDB, oldest.ID, second.ID),
|
||||
database.DeliveryStatusDelivered, now.Add(-49*time.Hour))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, yesterday.ID, second.ID),
|
||||
database.DeliveryStatusFailed, now.Add(-29*time.Hour))
|
||||
statsDelivery(t, webhookDB, middle.ID, first.ID),
|
||||
database.DeliveryStatusFailed, now.Add(-5*time.Hour))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, middle.ID, second.ID),
|
||||
database.DeliveryStatusFailed, now.Add(-5*time.Hour))
|
||||
database.DeliveryStatusFailed, now.Add(-time.Minute))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, newest.ID, first.ID),
|
||||
database.DeliveryStatusDelivered, now.Add(-2*time.Minute))
|
||||
@@ -244,11 +233,8 @@ func statsPrune(
|
||||
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.
|
||||
// statsPane returns the statistics pane from a rendered webhook page:
|
||||
// everything from its heading to the next heading on the page.
|
||||
func statsPane(t *testing.T, page string) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -256,62 +242,8 @@ func statsPane(t *testing.T, page string) string {
|
||||
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 nine deliveries and the replay, the
|
||||
// second nine; 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: 10, Delivered: 1, Failed: 2},
|
||||
second: {TargetID: second, Deliveries: 9, 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},
|
||||
{TargetID: second, Failed: 1},
|
||||
}, 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 11 (10 active) "+
|
||||
"Targets 5 (2 active) "+
|
||||
"Deliveries in progress 12 "+
|
||||
"Last event "+lastEvent+" "+
|
||||
"Retention 14 days")
|
||||
assert.Contains(t, pane, "Lifetime Within retention "+
|
||||
"Events 9 8 "+
|
||||
"Deliveries 19 16 "+
|
||||
"Failures 4 3")
|
||||
assert.Contains(t, pane, "Last 10 minutes Last 24 hours "+
|
||||
"Events 6 7 "+
|
||||
"Failures 0 1 "+
|
||||
"Failure percentage 0.0% 50.0%")
|
||||
return pane
|
||||
}
|
||||
|
||||
// TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure
|
||||
@@ -340,137 +272,74 @@ func TestWebhookStats_EveryFigureAcrossRetentionPrune(t *testing.T) {
|
||||
stats := h.WebhookStatsForTest(hist.webhook.ID)
|
||||
require.NotNil(t, stats)
|
||||
|
||||
assert.Equal(t, 11, stats.Entrypoints)
|
||||
assert.Equal(t, 10, stats.ActiveEntrypoints)
|
||||
assert.Equal(t, 5, stats.Targets)
|
||||
assert.Equal(t, 2, stats.Entrypoints)
|
||||
assert.Equal(t, 1, stats.ActiveEntrypoints)
|
||||
assert.Equal(t, 3, stats.Targets)
|
||||
assert.Equal(t, 2, stats.ActiveTargets)
|
||||
assert.Equal(t, handlers.Counts{Events: 9, Deliveries: 19, Failures: 4},
|
||||
assert.Equal(t, handlers.Counts{Events: 3, Deliveries: 7, Failures: 3},
|
||||
stats.Lifetime)
|
||||
assert.Equal(t, stats.Lifetime, stats.WithinRetention)
|
||||
assert.Equal(t, int64(14), stats.InProgress)
|
||||
assert.Equal(t, int64(2), stats.InProgress)
|
||||
require.NotNil(t, stats.LastEventAt)
|
||||
assert.True(t, hist.newest.CreatedAt.Equal(*stats.LastEventAt))
|
||||
assert.Equal(t, handlers.RecentWindow{Events: 6, Delivered: 1},
|
||||
stats.Last10Minutes)
|
||||
assert.Equal(t, handlers.RecentWindow{
|
||||
Events: 7, Delivered: 1, Failed: 1,
|
||||
Events: 1, Delivered: 1, Failed: 1,
|
||||
}, stats.Last10Minutes)
|
||||
assert.Equal(t, handlers.RecentWindow{
|
||||
Events: 2, Delivered: 1, Failed: 2,
|
||||
}, stats.Last24Hours)
|
||||
assert.Equal(t, "0.0%", stats.Last10Minutes.FailurePercent())
|
||||
assert.Equal(t, "50.0%", stats.Last24Hours.FailurePercent())
|
||||
assert.Equal(t, "50.0%", stats.Last10Minutes.FailurePercent())
|
||||
assert.Equal(t, "66.7%", stats.Last24Hours.FailurePercent())
|
||||
|
||||
assertStatsTargets(t, hist)
|
||||
// The first target has three deliveries and the replay, the second
|
||||
// three; the inactive target has none and so no row.
|
||||
assert.Equal(t, map[string]database.TargetTotals{
|
||||
first: {TargetID: first, Deliveries: 4, Delivered: 1, Failed: 2},
|
||||
second: {
|
||||
TargetID: second, Deliveries: 3, Delivered: 1, Failed: 1,
|
||||
},
|
||||
}, 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: 1},
|
||||
}, lastDay)
|
||||
|
||||
// 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.
|
||||
// second target's delivered 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: 8, Deliveries: 16, Failures: 3},
|
||||
assert.Equal(t, handlers.Counts{Events: 2, Deliveries: 4, Failures: 2},
|
||||
after.WithinRetention)
|
||||
assert.Equal(t, int64(12), after.InProgress)
|
||||
assert.Equal(t, int64(1), after.InProgress)
|
||||
assert.Equal(t, stats.LastEventAt, after.LastEventAt)
|
||||
assert.Equal(t, stats.Last10Minutes, after.Last10Minutes)
|
||||
assert.Equal(t, stats.Last24Hours, after.Last24Hours)
|
||||
|
||||
assert.Equal(t, map[string]database.TargetTotals{
|
||||
first: {
|
||||
TargetID: first, Deliveries: 10, Delivered: 1, Failed: 2,
|
||||
TargetID: first, Deliveries: 4, Delivered: 1, Failed: 2,
|
||||
DeliveriesRemoved: 2, FailedRemoved: 1,
|
||||
},
|
||||
second: {
|
||||
TargetID: second, Deliveries: 9, Failed: 2,
|
||||
TargetID: second, Deliveries: 3, Delivered: 1, Failed: 1,
|
||||
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")
|
||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
||||
assert.Contains(t, pane, "Within retention")
|
||||
assert.Contains(t, pane, "50.0%")
|
||||
assert.Contains(t, pane, "66.7%")
|
||||
}
|
||||
|
||||
// TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics
|
||||
@@ -508,7 +377,8 @@ func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.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)
|
||||
assert.Contains(t, pane, "Retention", tt.want)
|
||||
assert.Contains(t, pane, tt.want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,9 +406,7 @@ func TestWebhookStats_WebhookWithNoEvents(t *testing.T) {
|
||||
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 — —")
|
||||
statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||
assert.False(t, dbMgr.DBExists(wh.ID))
|
||||
}
|
||||
|
||||
|
||||
@@ -108,10 +108,10 @@ type failureWindow struct {
|
||||
//
|
||||
// A limiter that spends budget on arrival cannot protect a
|
||||
// single-admin product: behind the reverse proxy the deployment
|
||||
// requires, when TRUSTED_PROXIES does not cover it, every client
|
||||
// keys on the proxy, so a stranger trickling five POSTs a minute
|
||||
// keeps the one bucket full and the operator's own correct password
|
||||
// is answered 429 forever. There is no second administrative path.
|
||||
// requires, with TRUSTED_PROXIES unset, every client keys on the
|
||||
// proxy, so a stranger trickling five POSTs a minute keeps the one
|
||||
// bucket full and the operator's own correct password is answered 429
|
||||
// forever. There is no second administrative path.
|
||||
//
|
||||
// So budget is spent only by a FAILED verification. A correct
|
||||
// password is never throttled, whatever the counters say, which is
|
||||
|
||||
@@ -123,8 +123,9 @@ func bucketKey(addr netip.Addr) string {
|
||||
return prefix.String()
|
||||
}
|
||||
|
||||
// isTrustedProxy reports whether addr belongs to a network in
|
||||
// TRUSTED_PROXIES, which by default is the RFC 1918 private ranges.
|
||||
// isTrustedProxy reports whether addr belongs to a network the
|
||||
// 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 {
|
||||
for _, prefix := range m.params.Config.TrustedProxies {
|
||||
if prefix.Contains(addr) {
|
||||
|
||||
@@ -384,8 +384,8 @@ const (
|
||||
// trustedProxyCIDR is the proxy network the forwarded-path
|
||||
// tests configure, and trustedPeer an address inside it. A
|
||||
// production deployment is required to run behind a reverse
|
||||
// proxy that TRUSTED_PROXIES covers, either by the default or by
|
||||
// a set value, so this is the shape the bucketing has to hold in.
|
||||
// proxy with TRUSTED_PROXIES set, so this is the shape the
|
||||
// bucketing has to hold in.
|
||||
trustedProxyCIDR = "10.0.0.0/8"
|
||||
trustedPeer = "10.0.0.1:44444"
|
||||
)
|
||||
@@ -426,8 +426,8 @@ func assertSharedBucket(
|
||||
}
|
||||
|
||||
// TestRateLimitKey_SpoofedForwardedFromUntrustedPeer is the test
|
||||
// this gating exists for: from a peer that is not a trusted
|
||||
// proxy, a client that rotates a forwarded header on every
|
||||
// this gating exists for: with no trusted proxies configured (the
|
||||
// default), a client that rotates a forwarded header on every
|
||||
// request must stay in one bucket. If forwarded headers were
|
||||
// trusted unconditionally, each spoofed value would mint a fresh
|
||||
// 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
|
||||
// names forwarded as its client in X-Forwarded-For. That is the
|
||||
// production path: a deployment is required to run behind a reverse
|
||||
// proxy that TRUSTED_PROXIES covers, either by the default or by a
|
||||
// set value, so the forwarded address, not the peer, is what the
|
||||
// limiters bucket on there.
|
||||
// proxy with TRUSTED_PROXIES set, so the forwarded address, not the
|
||||
// peer, is what the limiters bucket on there.
|
||||
func forwardedKeyFor(
|
||||
t *testing.T, m *middleware.Middleware, forwarded string,
|
||||
) string {
|
||||
@@ -1179,9 +1178,9 @@ func TestRateLimitKey_ForwardedIPv6BucketsByPrefix(t *testing.T) {
|
||||
//
|
||||
// Every existing test of this fallback uses an IPv4 proxy, where
|
||||
// bucketKey is the identity function, so replacing the call with
|
||||
// peer.String() leaves the whole suite green. Only addresses inside
|
||||
// TRUSTED_PROXIES reach this line and the fallback is fail-closed, so
|
||||
// this pins behaviour rather than fixing a defect.
|
||||
// peer.String() leaves the whole suite green. Only operator-listed
|
||||
// addresses reach this line and the fallback is fail-closed, so this
|
||||
// pins behaviour rather than fixing a defect.
|
||||
func TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer(
|
||||
t *testing.T,
|
||||
) {
|
||||
|
||||
@@ -140,7 +140,7 @@ func (n *noopEvictor) EvictWebhook(string) {}
|
||||
// and the database, exactly as internal/handlers builds them.
|
||||
//
|
||||
// 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.
|
||||
func newServerApp(
|
||||
t *testing.T, dir string,
|
||||
|
||||
@@ -154,12 +154,11 @@ func (s *Server) setupPageRoutes() {
|
||||
r.Use(s.mw.NoCache())
|
||||
|
||||
// The login POST carries no pre-emptive rate limiter. Behind
|
||||
// the reverse proxy production requires, when TRUSTED_PROXIES
|
||||
// does not cover it, every client shares one bucket, so a
|
||||
// limiter spent on arrival lets any stranger deny the operator
|
||||
// the only administrative path. The handler verifies
|
||||
// credentials first and charges only failures; see
|
||||
// Handlers.authenticateUser.
|
||||
// the reverse proxy production requires, with TRUSTED_PROXIES
|
||||
// unset, every client shares one bucket, so a limiter spent
|
||||
// on arrival lets any stranger deny the operator the only
|
||||
// administrative path. The handler verifies credentials first
|
||||
// and charges only failures; see Handlers.authenticateUser.
|
||||
r.Get("/login", s.h.HandleLoginPage())
|
||||
r.Post("/login", s.h.HandleLoginSubmit())
|
||||
|
||||
|
||||
+1
-6
@@ -22,11 +22,6 @@
|
||||
# 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
|
||||
# 67s, that is the datum to revisit the org figure with.
|
||||
#
|
||||
# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test
|
||||
# binaries build or run at once, each with at most eight parallel tests. Under
|
||||
# -race every test binary and every link costs a few hundred MB, so the
|
||||
# defaults (one per core) add up to several GB on a many-core host.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
@@ -34,7 +29,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
"$ROOT/script/assets"
|
||||
go test -v -race -p 4 -parallel 8 -timeout 90s ./...
|
||||
go test -v -race -timeout 90s ./...
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
<dt class="w-32 text-sm font-medium text-gray-500">Username</dt>
|
||||
<dd class="text-sm text-gray-900">{{.User.Username}}</dd>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -183,30 +183,18 @@
|
||||
<!-- Recent Events -->
|
||||
<div class="card mt-6">
|
||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2>
|
||||
<h2 class="text-lg font-medium text-gray-900">Recent Events</h2>
|
||||
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Events}}
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="badge-info">{{.Method}}</span>
|
||||
<span class="text-sm text-gray-500 break-all">{{.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>
|
||||
<span class="text-sm text-gray-500">{{.ContentType}}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</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>
|
||||
<span class="font-medium text-gray-900">{{with .LastEventAt}}{{.Format "2006-01-02 15:04:05 UTC"}}{{else}}none{{end}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Retention</span>
|
||||
@@ -30,34 +30,32 @@
|
||||
</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>
|
||||
<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>
|
||||
<table class="w-full text-center text-gray-900">
|
||||
<thead>
|
||||
|
||||
Reference in New Issue
Block a user