Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03b19c48e9 | ||
|
|
98f719ded3 | ||
|
|
0da5f26bdf | ||
|
|
a9f7448ff6 | ||
|
|
999654f8f6 | ||
|
|
e1a11933f0 |
@@ -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` |
|
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive) | `1h` |
|
||||||
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
|
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
|
||||||
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` |
|
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` |
|
||||||
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted (unset: all clients behind a proxy share one rate-limit bucket; a correct login password is never throttled either way) | `""` (none) |
|
| `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) |
|
||||||
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
||||||
|
|
||||||
#### Allowing egress to your own network
|
#### Allowing egress to your own network
|
||||||
@@ -374,41 +374,37 @@ unlocked.
|
|||||||
`TRUSTED_PROXIES` is a comma-separated list of CIDR blocks (a bare
|
`TRUSTED_PROXIES` is a comma-separated list of CIDR blocks (a bare
|
||||||
address such as `192.168.1.7` is accepted and treated as a single
|
address such as `192.168.1.7` is accepted and treated as a single
|
||||||
host), for example `192.168.1.7, 2001:db8::5`. It decides whose
|
host), for example `192.168.1.7, 2001:db8::5`. It decides whose
|
||||||
`X-Forwarded-For` header the rate limiters believe, so it should name
|
`X-Forwarded-For` header the rate limiters believe, so it should cover
|
||||||
the addresses of your reverse proxies and nothing else.
|
the addresses of your reverse proxies.
|
||||||
|
|
||||||
`X-Forwarded-For` is honoured **only** when the connecting peer is
|
`X-Forwarded-For` is honoured **only** when the connecting peer is
|
||||||
inside one of these blocks; for every other peer the client identity is
|
inside one of these blocks; for every other peer the client identity is
|
||||||
the connection's own address and the header is ignored. The default is
|
the connection's own address and the header is ignored. Unset (or
|
||||||
the empty list, which trusts nobody — anything else would let any
|
empty), the list is the RFC 1918 private ranges: `10.0.0.0/8`,
|
||||||
client pick its own rate limit bucket, minting a fresh one per request
|
`172.16.0.0/12` and `192.168.0.0/16`. A set value replaces the default
|
||||||
or draining someone else's. Set it to the address of your reverse
|
entirely. A set but unparseable value aborts startup.
|
||||||
proxy, and to nothing wider. A set but unparseable value aborts
|
|
||||||
startup.
|
|
||||||
|
|
||||||
That default is safe against forged headers, but leaving it unset in
|
If any client can reach webhooker, or the proxy in front of it, from an
|
||||||
production has a cost you must know about. Production runs behind a
|
RFC 1918 source address (directly, or through anything that can
|
||||||
TLS-terminating reverse proxy, so with `TRUSTED_PROXIES` unset every
|
rewrite source addresses, such as NAT or a published container port),
|
||||||
request keys on the proxy's own address and all clients share a single
|
set `TRUSTED_PROXIES` to the proxy's address alone, or every rate
|
||||||
bucket per limit. The receiver limits become service-wide ceilings,
|
limit, the webhook receiver's included, can be bypassed by those
|
||||||
and the login endpoint's failure counting collapses onto one key, so a
|
clients. The address to set is the `remoteIP` field of the
|
||||||
stranger's wrong passwords throttle every other client's wrong
|
`http request` log line for a request that came through the proxy.
|
||||||
passwords.
|
|
||||||
|
Behind a proxy the list does not cover, every request keys on the
|
||||||
|
proxy's own address and all clients share a single bucket per limit.
|
||||||
|
The receiver limits become service-wide ceilings, and the login
|
||||||
|
endpoint's failure counting collapses onto one key, so a stranger's
|
||||||
|
wrong passwords throttle every other client's wrong passwords. Set
|
||||||
|
`TRUSTED_PROXIES` to that proxy's address to restore per-client
|
||||||
|
buckets.
|
||||||
|
|
||||||
What it cannot do is lock the operator out. The login endpoint
|
What it cannot do is lock the operator out. The login endpoint
|
||||||
verifies credentials **before** it consults any limit and charges only
|
verifies credentials **before** it consults any limit and charges only
|
||||||
failures, so a correct password is never throttled no matter how full
|
failures, so a correct password is never throttled no matter how full
|
||||||
the bucket is. See [Rate Limiting](#rate-limiting).
|
the bucket is. See [Rate Limiting](#rate-limiting).
|
||||||
|
|
||||||
The remedy is to set `TRUSTED_PROXIES` to your reverse proxy's
|
|
||||||
address, which restores per-client buckets. webhooker logs a warning
|
|
||||||
at startup whenever `TRUSTED_PROXIES` is empty, in every environment,
|
|
||||||
because behind a proxy every client shares one bucket in `dev` and
|
|
||||||
`prod` alike. The warning is informational when nothing proxies to the
|
|
||||||
process: with no proxy in front, the peer address is the client's own
|
|
||||||
and the buckets are already per-client. See
|
|
||||||
[Rate Limiting](#rate-limiting) for what each limit shares.
|
|
||||||
|
|
||||||
`X-Real-IP` and `True-Client-IP` are **never** read, from any peer.
|
`X-Real-IP` and `True-Client-IP` are **never** read, from any peer.
|
||||||
Reverse proxies append to `X-Forwarded-For` but forward other client
|
Reverse proxies append to `X-Forwarded-For` but forward other client
|
||||||
headers verbatim, so a single-valued header is client-controlled even
|
headers verbatim, so a single-valued header is client-controlled even
|
||||||
@@ -424,20 +420,10 @@ instead, since past such an entry the chain is not the shape assumed
|
|||||||
here. The peer address is likewise used when the header is absent or
|
here. The peer address is likewise used when the header is absent or
|
||||||
every hop in it is a trusted proxy.
|
every hop in it is a trusted proxy.
|
||||||
|
|
||||||
Two operator requirements follow:
|
Your proxy must therefore **append** the peer address to
|
||||||
|
`X-Forwarded-For` (nginx `$proxy_add_x_forwarded_for`, HAProxy
|
||||||
- Your proxy must **append** the peer address to `X-Forwarded-For`
|
`option forwardfor`, Caddy and AWS ALB by default), and must append a
|
||||||
(nginx `$proxy_add_x_forwarded_for`, HAProxy `option forwardfor`,
|
bare address with no port.
|
||||||
Caddy and AWS ALB by default), and must append a bare address with
|
|
||||||
no port.
|
|
||||||
- List proxy hosts **only**. Any address inside `TRUSTED_PROXIES`
|
|
||||||
chooses its own rate-limit key: its `X-Forwarded-For` is walked, so
|
|
||||||
it can name a different address on every request to get a fresh
|
|
||||||
bucket each time, or name another client's address to drain that
|
|
||||||
client's bucket. Never list a block that also covers clients — a
|
|
||||||
broad `10.0.0.0/8` on a network where clients live in the same range
|
|
||||||
makes all three limits, including the unauthenticated webhook
|
|
||||||
receiver, silently bypassable by every client in the block.
|
|
||||||
|
|
||||||
#### Sessions
|
#### Sessions
|
||||||
|
|
||||||
@@ -736,10 +722,15 @@ repository's `Dockerfile` and runs it. The app needs:
|
|||||||
- **Volume:** one host directory mounted at `/var/lib/webhooker`.
|
- **Volume:** one host directory mounted at `/var/lib/webhooker`.
|
||||||
- **Environment variables:**
|
- **Environment variables:**
|
||||||
- `WEBHOOKER_ENVIRONMENT=prod`
|
- `WEBHOOKER_ENVIRONMENT=prod`
|
||||||
- `TRUSTED_PROXIES`: your reverse proxy's address on that Docker
|
- `TRUSTED_PROXIES`: unset, it is the RFC 1918 ranges. Set it to
|
||||||
network. The `remoteIP` field of the `http request` log line for a
|
your reverse proxy's address alone if that address is outside
|
||||||
request that came through the proxy shows it; the health check's
|
those ranges, or if any client can reach webhooker, or the proxy,
|
||||||
own lines show `::1`. See [Trusted proxies](#trusted-proxies).
|
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).
|
||||||
- Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets
|
- Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets
|
||||||
`BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to
|
`BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to
|
||||||
`/var/lib/webhooker`.
|
`/var/lib/webhooker`.
|
||||||
@@ -802,12 +793,16 @@ reports.
|
|||||||
behind a proxy means the `X-Forwarded-Proto` header. The block below
|
behind a proxy means the `X-Forwarded-Proto` header. The block below
|
||||||
sets it; without it every request is read as plaintext and cookies
|
sets it; without it every request is read as plaintext and cookies
|
||||||
ship without `Secure`. See [Configuration](#configuration).
|
ship without `Secure`. See [Configuration](#configuration).
|
||||||
3. **Set `TRUSTED_PROXIES` to the proxy's address.** Unset, every rate
|
3. **Make sure `TRUSTED_PROXIES` covers the proxy's address.** For a
|
||||||
limiter keys on the connecting peer, which behind a proxy is the
|
proxy it does not cover, every rate limiter keys on the proxy, so
|
||||||
proxy on every request: all clients collapse into one global bucket
|
all clients share one bucket per limit. Unset, the list is the RFC
|
||||||
per limit and the receiver's per-IP limits become service-wide
|
1918 ranges, which do not cover a proxy that reaches the binary
|
||||||
ceilings. See [Trusted proxies](#trusted-proxies). List the proxy
|
itself over loopback (the binary bound to `127.0.0.1`). With the
|
||||||
and nothing else.
|
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).
|
||||||
4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the
|
4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the
|
||||||
port. webhooker's Origin/Referer check compares against the host it
|
port. webhooker's Origin/Referer check compares against the host it
|
||||||
was given, so on any port other than 443 `$host` makes every form
|
was given, so on any port other than 443 `$host` makes every form
|
||||||
@@ -1065,7 +1060,7 @@ unconditionally against whatever files it finds:
|
|||||||
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
||||||
`Entrypoint`, `Target`
|
`Entrypoint`, `Target`
|
||||||
- each event database when it is lazily opened — `Event`, `Delivery`,
|
- each event database when it is lazily opened — `Event`, `Delivery`,
|
||||||
`DeliveryResult`, `EventTotals`, `TargetTotals`
|
`DeliveryResult`
|
||||||
- each archive database on every open and reopen
|
- each archive database on every open and reopen
|
||||||
|
|
||||||
There is no schema version table, no migration ledger, and no down
|
There is no schema version table, no migration ledger, and no down
|
||||||
@@ -1360,10 +1355,11 @@ It uses:
|
|||||||
- **[go-chi/httprate](https://github.com/go-chi/httprate)** for
|
- **[go-chi/httprate](https://github.com/go-chi/httprate)** for
|
||||||
sliding-window rate limiting of the password-change and webhook
|
sliding-window rate limiting of the password-change and webhook
|
||||||
receiver endpoints. The bucket is per client IP only when
|
receiver endpoints. The bucket is per client IP only when
|
||||||
`TRUSTED_PROXIES` names the reverse proxy; unset, every client
|
`TRUSTED_PROXIES` covers the reverse proxy (by default it covers the
|
||||||
behind that proxy shares one bucket per limit. The login endpoint
|
RFC 1918 private ranges); otherwise every client behind that proxy
|
||||||
counts failed attempts itself instead, so that a correct password is
|
shares one bucket per limit. The login endpoint counts failed
|
||||||
never throttled (see [Rate Limiting](#rate-limiting))
|
attempts itself instead, so that a correct password is never
|
||||||
|
throttled (see [Rate Limiting](#rate-limiting))
|
||||||
- **[Prometheus](https://prometheus.io)** for metrics, served at
|
- **[Prometheus](https://prometheus.io)** for metrics, served at
|
||||||
`/metrics` behind basic auth
|
`/metrics` behind basic auth
|
||||||
- **[Sentry](https://sentry.io)** for optional error reporting
|
- **[Sentry](https://sentry.io)** for optional error reporting
|
||||||
@@ -1381,7 +1377,7 @@ The codebase uses consistent naming throughout (rename completed in
|
|||||||
|
|
||||||
### Data Model
|
### Data Model
|
||||||
|
|
||||||
webhooker's data model has eleven entities organized into two tiers: the
|
webhooker's data model has nine entities organized into two tiers: the
|
||||||
**application tier** (user and webhook configuration) and the **event
|
**application tier** (user and webhook configuration) and the **event
|
||||||
tier** (event ingestion, delivery, and logging).
|
tier** (event ingestion, delivery, and logging).
|
||||||
|
|
||||||
@@ -1410,13 +1406,6 @@ tier** (event ingestion, delivery, and logging).
|
|||||||
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
|
||||||
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
|
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
|
||||||
│ └──────────┘ └──────────┘ └─────────────────┘ │
|
│ └──────────┘ └──────────┘ └─────────────────┘ │
|
||||||
│ │
|
|
||||||
│ ┌──────────────┐ (one row: running counts of events) │
|
|
||||||
│ │ EventTotals │ │
|
|
||||||
│ └──────────────┘ │
|
|
||||||
│ ┌──────────────┐ (one row per target: running counts │
|
|
||||||
│ │ TargetTotals │ of its deliveries) │
|
|
||||||
│ └──────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1669,7 +1658,6 @@ status across potentially multiple attempts.
|
|||||||
| `event_id` | UUID | Foreign key → Event |
|
| `event_id` | UUID | Foreign key → Event |
|
||||||
| `target_id`| UUID | Foreign key → Target |
|
| `target_id`| UUID | Foreign key → Target |
|
||||||
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
|
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
|
||||||
| `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) |
|
|
||||||
|
|
||||||
**Relations:** Belongs to Event. Belongs to Target. Has many
|
**Relations:** Belongs to Event. Belongs to Target. Has many
|
||||||
DeliveryResults.
|
DeliveryResults.
|
||||||
@@ -1737,65 +1725,33 @@ retries) is individually logged for full observability.
|
|||||||
|
|
||||||
**Relations:** Belongs to Delivery.
|
**Relations:** Belongs to Delivery.
|
||||||
|
|
||||||
#### EventTotals and TargetTotals
|
|
||||||
|
|
||||||
Running counts in each event database, read by the statistics pane at the
|
|
||||||
top of the webhook page. `EventTotals` is one row:
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
| ---------------- | ------- | ----------- |
|
|
||||||
| `events` | integer | Events ever stored, resubmitted copies included |
|
|
||||||
| `events_removed` | integer | Events retention has deleted |
|
|
||||||
|
|
||||||
`TargetTotals` is one row per target, created by the first delivery to it:
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
| -------------------- | ------- | ----------- |
|
|
||||||
| `target_id` | UUID | The target (primary key) |
|
|
||||||
| `deliveries` | integer | Deliveries to it ever created, replays included |
|
|
||||||
| `delivered` | integer | Of those, how many became `delivered` |
|
|
||||||
| `failed` | integer | Of those, how many became `failed` |
|
|
||||||
| `deliveries_removed` | integer | Its deliveries retention has deleted |
|
|
||||||
| `failed_removed` | integer | Its failed deliveries retention has deleted |
|
|
||||||
|
|
||||||
Each count changes in the transaction that writes or deletes the rows it
|
|
||||||
counts. The pane's lifetime events are `events`, and its lifetime
|
|
||||||
deliveries and failures are `deliveries` and `failed` summed over the
|
|
||||||
targets; each figure within retention is the same less what retention
|
|
||||||
removed, so neither needs the rows themselves. Its last-10-minutes and
|
|
||||||
last-24-hours figures are counted from the `events` and `deliveries`
|
|
||||||
indexes over just that window, the deliveries in one query grouped by
|
|
||||||
target. Its failure percentage for a window is the deliveries that became
|
|
||||||
`failed` in it out of all that became `delivered` or `failed` in it, and
|
|
||||||
a dash when none did.
|
|
||||||
|
|
||||||
#### Event-tier indexes
|
#### Event-tier indexes
|
||||||
|
|
||||||
These indexes on the per-webhook event databases are declared in the model
|
These indexes on the per-webhook event databases are declared in the model
|
||||||
tags, so `AutoMigrate` creates them on a fresh database:
|
tags, so `AutoMigrate` creates them on a fresh and on an existing database:
|
||||||
|
|
||||||
| Table | Columns | Serves |
|
| Table | Columns | Serves |
|
||||||
| ------------------ | --------------------------- | ------ |
|
| ------------------ | --------------------------- | ------ |
|
||||||
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics, which count each target's deliveries by status and when they finished |
|
| `deliveries` | `status`, `deleted_at` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status |
|
||||||
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
|
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which selects and deletes the deliveries of expired events |
|
||||||
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
||||||
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events and find the newest |
|
| `events` | `deleted_at`, `created_at` | Retention, which selects expired events by age |
|
||||||
| `events` | `created_at` | Retention, which selects expired events by age |
|
| `events` | `created_at` | Retention's delete of the expired events themselves |
|
||||||
|
|
||||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
|
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention's
|
||||||
leaves it out. SQLite keeps no statistics on these tables, and without them it
|
deletes leave it out, but their lookups of expired rows keep it. SQLite keeps
|
||||||
rates the `deleted_at` index, which every live row matches, above an index on
|
no statistics on these tables, and without them it rates the `deleted_at`
|
||||||
a column matched against several values or compared with `<`. So every index
|
index, which every live row matches, above an index on a column matched
|
||||||
but the last also covers `deleted_at`. It comes second, so that retention can
|
against several values or compared with `<`. So every index but the last also
|
||||||
use the index without it, except in `events`, where `created_at` is compared
|
covers `deleted_at`. It comes second, so that retention's deletes can use the
|
||||||
with `<` and SQLite narrows by a `<` only on the last column it uses.
|
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
|
#### Common Fields
|
||||||
|
|
||||||
Every entity except `Setting`, `EventTotals` and `TargetTotals` includes
|
Every entity except `Setting` includes these fields from `BaseModel`.
|
||||||
these fields from `BaseModel`. `Setting` is a bare key-value row with no
|
`Setting` is a bare key-value row with no `id`, no timestamps and no
|
||||||
`id`, no timestamps and no soft delete, and the two totals tables hold
|
soft delete:
|
||||||
only counts, keyed by a numeric `id` and by `target_id`:
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ------------ | --------- | ----------- |
|
| ------------ | --------- | ----------- |
|
||||||
@@ -1837,8 +1793,6 @@ encryption key is generated and stored, and an `admin` user is created.
|
|||||||
- **Events** — captured incoming webhook payloads
|
- **Events** — captured incoming webhook payloads
|
||||||
- **Deliveries** — event-to-target pairings and their status
|
- **Deliveries** — event-to-target pairings and their status
|
||||||
- **DeliveryResults** — individual delivery attempt logs
|
- **DeliveryResults** — individual delivery attempt logs
|
||||||
- **EventTotals** and **TargetTotals** — running counts of the above,
|
|
||||||
the deliveries per target, kept through retention
|
|
||||||
|
|
||||||
Per-webhook databases are created automatically when a webhook is
|
Per-webhook databases are created automatically when a webhook is
|
||||||
created (and lazily on first access for webhooks that predate this
|
created (and lazily on first access for webhooks that predate this
|
||||||
@@ -2575,47 +2529,44 @@ the tree is checked out: four checkouts have reported 3,959, 3,961,
|
|||||||
client-supplied field was cut, and that the shipped chain's stack
|
client-supplied field was cut, and that the shipped chain's stack
|
||||||
arrived uncut — never the numbers.
|
arrived uncut — never the numbers.
|
||||||
|
|
||||||
Every limiter here — receiver, login, and password change — identifies
|
Every limiter here — receiver, login, password change, delivery replay
|
||||||
the client the same way, through one shared key function: the
|
and event resubmit — identifies the client the same way, through one
|
||||||
connection's own address, unless the peer is listed in
|
shared key function: the connection's own address, unless the peer is
|
||||||
`TRUSTED_PROXIES`, in which case the forwarded client address is used
|
inside `TRUSTED_PROXIES`, in which case the forwarded client address is
|
||||||
instead. That address becomes a bucket by family: IPv4 keys on the full
|
used instead. That address becomes a bucket by family: IPv4 keys on
|
||||||
address, IPv6 on its `/64` prefix. A routed `/64` is the normal
|
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
|
residential and mobile IPv6 allocation, so keying IPv6 per address would
|
||||||
let one subscriber rotate source addresses and mint a fresh bucket per
|
let one subscriber rotate source addresses and mint a fresh bucket per
|
||||||
request, evading these limits at the network layer without spoofing
|
request, evading these limits at the network layer without spoofing
|
||||||
anything; the cost is that distinct clients inside one `/64` share a
|
anything; the cost is that distinct clients inside one `/64` share a
|
||||||
bucket. IPv4-mapped addresses (`::ffff:1.2.3.4`) key as the IPv4 address
|
bucket. IPv4-mapped addresses (`::ffff:1.2.3.4`) key as the IPv4 address
|
||||||
they carry. See [Trusted proxies](#trusted-proxies). Deployed without that
|
they carry. See [Trusted proxies](#trusted-proxies). When that variable
|
||||||
variable set, a client behind a reverse proxy shares one bucket with
|
does not cover the reverse proxy, a client behind it shares one bucket
|
||||||
every other client behind the same proxy. Set `TRUSTED_PROXIES` to the
|
with every other client behind the same proxy. Set `TRUSTED_PROXIES` to
|
||||||
proxy's address to get per-client limits back. What the shared bucket
|
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
|
costs is not the same for every limiter, and the two cases pull in
|
||||||
opposite directions:
|
opposite directions:
|
||||||
|
|
||||||
- For the **receiver** limits it costs throughput, which is the safe
|
- For the **receiver** limits it costs throughput, which is the safe
|
||||||
direction to be wrong in: sharing can only make a limit bind sooner,
|
direction to be wrong in: sharing can only make a limit bind sooner,
|
||||||
never let a sender past it. It matters more for the aggregate limit
|
never let a sender past it. It matters more for the aggregate limit
|
||||||
than for the per-entrypoint one: with `TRUSTED_PROXIES` unset behind
|
than for the per-entrypoint one: with every request keyed on the
|
||||||
the reverse proxy a production deployment is required to run behind,
|
proxy, the aggregate limit becomes a service-wide ceiling of 1200
|
||||||
every request keys on the proxy, so the aggregate limit becomes a
|
requests per minute across all senders and all entrypoints, where the
|
||||||
service-wide ceiling of 1200 requests per minute across all senders
|
per-entrypoint limit's capacity still grows with the number of
|
||||||
and all entrypoints, where the per-entrypoint limit's capacity still
|
entrypoints.
|
||||||
grows with the number of entrypoints. Any deployment with more than a
|
|
||||||
handful of busy entrypoints must set `TRUSTED_PROXIES`.
|
|
||||||
- For the **login and password-change** limits it costs precision, not
|
- For the **login and password-change** limits it costs precision, not
|
||||||
availability. Login failures from every client land in one counter,
|
availability. Login failures from every client land in one counter,
|
||||||
so a stranger's wrong passwords make the operator's own wrong
|
so a stranger's wrong passwords make the operator's own wrong
|
||||||
passwords answer `429` sooner; the operator's _correct_ password is
|
passwords answer `429` sooner; the operator's _correct_ password is
|
||||||
never affected, because it is never counted. Production deployments
|
never affected, because it is never counted.
|
||||||
should still set `TRUSTED_PROXIES`; webhooker warns at startup
|
|
||||||
whenever it is empty, in any environment.
|
|
||||||
|
|
||||||
#### The login endpoint
|
#### The login endpoint
|
||||||
|
|
||||||
The login `POST` is the one endpoint with no pre-emptive limiter in
|
The login `POST` is the one endpoint with no pre-emptive limiter in
|
||||||
front of it, and that is deliberate. A limiter that spends budget on
|
front of it, and that is deliberate. A limiter that spends budget on
|
||||||
arrival is a lockout in this deployment shape: sharing one bucket, a
|
arrival is a lockout wherever clients share one bucket, as they do
|
||||||
|
behind a reverse proxy that `TRUSTED_PROXIES` does not cover: a
|
||||||
stranger sending five POSTs a minute — about 0.08 requests per second,
|
stranger sending five POSTs a minute — about 0.08 requests per second,
|
||||||
from anywhere — keeps it permanently full, and the operator has no
|
from anywhere — keeps it permanently full, and the operator has no
|
||||||
second administrative path. So the handler inverts the order:
|
second administrative path. So the handler inverts the order:
|
||||||
@@ -2712,8 +2663,10 @@ re-fills both verification slots on its first two requests. The
|
|||||||
remedies are to block the source at the reverse proxy, or to
|
remedies are to block the source at the reverse proxy, or to
|
||||||
rate-limit `POST /pages/login` there — the one place a limit can be
|
rate-limit `POST /pages/login` there — the one place a limit can be
|
||||||
applied without reintroducing the lockout, because the proxy sees the
|
applied without reintroducing the lockout, because the proxy sees the
|
||||||
real client address. Setting `TRUSTED_PROXIES` does not stop the
|
real client address. `TRUSTED_PROXIES` does not stop the saturation.
|
||||||
saturation, but it makes the source visible in the failure logs.
|
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)).
|
||||||
|
|
||||||
Finer-grained per-webhook rate limits (configured in the web UI and
|
Finer-grained per-webhook rate limits (configured in the web UI and
|
||||||
enforced in the webhook handler) can layer on top of this env-level
|
enforced in the webhook handler) can layer on top of this env-level
|
||||||
@@ -2819,7 +2772,6 @@ webhooker/
|
|||||||
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
||||||
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
||||||
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
||||||
│ │ ├── model_totals.go # EventTotals and TargetTotals (per-webhook DB)
|
|
||||||
│ │ ├── model_apikey.go # APIKey entity
|
│ │ ├── model_apikey.go # APIKey entity
|
||||||
│ │ ├── password.go # Argon2id hashing and verification
|
│ │ ├── password.go # Argon2id hashing and verification
|
||||||
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
||||||
@@ -3072,10 +3024,9 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
It runs behind session auth, so only a client already holding a
|
It runs behind session auth, so only a client already holding a
|
||||||
valid session reaches it, and an operator throttled out of changing
|
valid session reaches it, and an operator throttled out of changing
|
||||||
a password can still log in. The bucket is per client IP only when
|
a password can still log in. The bucket is per client IP only when
|
||||||
`TRUSTED_PROXIES` names the reverse proxy; unset, every client
|
`TRUSTED_PROXIES` covers the reverse proxy; otherwise every client
|
||||||
shares one bucket, which costs precision rather than availability
|
shares one bucket, which costs precision rather than availability
|
||||||
(see [Rate Limiting](#rate-limiting)). webhooker warns at startup
|
(see [Rate Limiting](#rate-limiting))
|
||||||
whenever `TRUSTED_PROXIES` is empty
|
|
||||||
- Prometheus metrics behind basic auth
|
- Prometheus metrics behind basic auth
|
||||||
- Static assets embedded in binary (no filesystem access needed at
|
- Static assets embedded in binary (no filesystem access needed at
|
||||||
runtime)
|
runtime)
|
||||||
|
|||||||
+22
-60
@@ -75,6 +75,11 @@ const (
|
|||||||
// internet-exposed endpoint.
|
// internet-exposed endpoint.
|
||||||
defaultReceiverRateLimit = 120
|
defaultReceiverRateLimit = 120
|
||||||
|
|
||||||
|
// defaultTrustedProxies is TRUSTED_PROXIES when it is unset: the
|
||||||
|
// RFC 1918 private ranges, which a reverse proxy reaching the
|
||||||
|
// process over a Docker network or a private LAN connects from.
|
||||||
|
defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||||
|
|
||||||
// maxPort is the highest valid TCP port number. The lower
|
// maxPort is the highest valid TCP port number. The lower
|
||||||
// bound (at least 1) is enforced by envPositiveInt.
|
// bound (at least 1) is enforced by envPositiveInt.
|
||||||
maxPort = 65535
|
maxPort = 65535
|
||||||
@@ -172,13 +177,14 @@ type Config struct {
|
|||||||
|
|
||||||
// TrustedProxies is the set of networks whose members are
|
// TrustedProxies is the set of networks whose members are
|
||||||
// allowed to speak for the client with X-Forwarded-For, the
|
// allowed to speak for the client with X-Forwarded-For, the
|
||||||
// only forwarded header read. It is empty unless
|
// only forwarded header read. Unless TRUSTED_PROXIES is set it
|
||||||
// TRUSTED_PROXIES is set, and empty means no peer is
|
// is the RFC 1918 private ranges (defaultTrustedProxies); a set
|
||||||
// trusted: forwarded headers are then ignored entirely and
|
// value replaces them. If any client can reach the process, or
|
||||||
// clients are identified by the connection's own address.
|
// the proxy in front of it, from an RFC 1918 source address
|
||||||
// Members can choose their own rate-limit key, so this must
|
// (directly, or through anything that can rewrite source
|
||||||
// name proxy hosts only, never a block that also covers
|
// addresses, such as NAT or a published container port), it
|
||||||
// clients.
|
// must be set to the proxy's address alone, or every rate limit
|
||||||
|
// can be bypassed by those clients.
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies []netip.Prefix
|
||||||
|
|
||||||
// AllowedEgressCIDRs is the set of networks a delivery target
|
// AllowedEgressCIDRs is the set of networks a delivery target
|
||||||
@@ -460,14 +466,15 @@ func parseCIDR(entry string) (netip.Prefix, error) {
|
|||||||
|
|
||||||
// envPrefixList returns the value of the named environment variable
|
// envPrefixList returns the value of the named environment variable
|
||||||
// parsed as a comma-separated list of CIDR blocks (bare addresses
|
// parsed as a comma-separated list of CIDR blocks (bare addresses
|
||||||
// allowed). An unset, empty, or blank value yields an empty list. A
|
// allowed). An unset, empty, or blank value is read as defaultValue
|
||||||
// set value containing an unparseable entry is a hard error naming
|
// instead. A set value containing an unparseable entry is a hard
|
||||||
// the key and the bad entry, so startup fails loudly rather than
|
// error naming the key and the bad entry, so startup fails loudly
|
||||||
// silently running with a list the operator did not intend.
|
// rather than silently running with a list the operator did not
|
||||||
func envPrefixList(key string) ([]netip.Prefix, error) {
|
// intend.
|
||||||
|
func envPrefixList(key, defaultValue string) ([]netip.Prefix, error) {
|
||||||
v := strings.TrimSpace(os.Getenv(key))
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
if v == "" {
|
if v == "" {
|
||||||
return nil, nil
|
v = defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
var prefixes []netip.Prefix
|
var prefixes []netip.Prefix
|
||||||
@@ -681,12 +688,12 @@ func loadFromEnv() (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
trustedProxies, err := envPrefixList("TRUSTED_PROXIES")
|
trustedProxies, err := envPrefixList("TRUSTED_PROXIES", defaultTrustedProxies)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS")
|
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -760,50 +767,6 @@ func (c *Config) warnEgressAllowlist(log *slog.Logger) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// warnSharedRateLimitBucket logs a startup warning whenever
|
|
||||||
// TRUSTED_PROXIES is empty, in any environment.
|
|
||||||
//
|
|
||||||
// With no trusted proxies every rate limiter keys on the connecting
|
|
||||||
// peer's address. Whether that is harmless or dangerous depends on
|
|
||||||
// what is in front of the process, which this code cannot observe:
|
|
||||||
// with nothing in front, the peer is the client and the limits are
|
|
||||||
// per-client as intended; behind a reverse proxy the peer is the proxy
|
|
||||||
// for every request, so all clients share one bucket per limiter.
|
|
||||||
//
|
|
||||||
// The login endpoint no longer spends budget on arrival — it verifies
|
|
||||||
// credentials first and charges only failures — so a shared bucket
|
|
||||||
// cannot deny the operator a correct password. What it does collapse
|
|
||||||
// is the failure counting: one client's wrong passwords throttle
|
|
||||||
// everyone else's wrong passwords, and the receiver's limits become
|
|
||||||
// service-wide ceilings.
|
|
||||||
//
|
|
||||||
// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT:
|
|
||||||
// behind a proxy every client shares one bucket in dev and prod alike.
|
|
||||||
//
|
|
||||||
// The default of trusting nobody is deliberate — trusting forwarded
|
|
||||||
// headers from arbitrary peers lets any client choose its own bucket —
|
|
||||||
// so this warns rather than failing startup or changing the key.
|
|
||||||
func (c *Config) warnSharedRateLimitBucket(log *slog.Logger) {
|
|
||||||
if len(c.TrustedProxies) > 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Warn(
|
|
||||||
"TRUSTED_PROXIES is empty: every rate limit keys on the "+
|
|
||||||
"connecting peer's address. With nothing proxying to "+
|
|
||||||
"this process that is the client itself and the limits "+
|
|
||||||
"are per-client as intended. Behind a reverse proxy the "+
|
|
||||||
"peer is the proxy on every request, so all clients "+
|
|
||||||
"share one bucket per limit: the receiver limits become "+
|
|
||||||
"service-wide ceilings, and one client's failed logins "+
|
|
||||||
"throttle every other client's failed logins — a "+
|
|
||||||
"correct password still gets in. If anything proxies to "+
|
|
||||||
"this process, set TRUSTED_PROXIES to its address.",
|
|
||||||
"environment", c.Environment,
|
|
||||||
"trustedProxies", len(c.TrustedProxies),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a Config by reading environment variables.
|
// New creates a Config by reading environment variables.
|
||||||
//
|
//
|
||||||
//nolint:revive // lc parameter is required by fx even if unused.
|
//nolint:revive // lc parameter is required by fx even if unused.
|
||||||
@@ -849,7 +812,6 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|||||||
"hasMetricsAuth", s.MetricsAuthEnabled(),
|
"hasMetricsAuth", s.MetricsAuthEnabled(),
|
||||||
)
|
)
|
||||||
|
|
||||||
s.warnSharedRateLimitBucket(log)
|
|
||||||
s.warnEgressAllowlist(log)
|
s.warnEgressAllowlist(log)
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
|
|||||||
+14
-101
@@ -551,6 +551,11 @@ func testReceiverRateLimitSuccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTrustedProxies(t *testing.T) {
|
func TestTrustedProxies(t *testing.T) {
|
||||||
|
// Unset, the RFC 1918 private ranges are trusted, so a reverse
|
||||||
|
// proxy on a Docker network or a private LAN is covered without
|
||||||
|
// configuration.
|
||||||
|
defaultProxies := []string{cidrPrivateV4, "172.16.0.0/12", "192.168.0.0/16"}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
set bool
|
set bool
|
||||||
@@ -559,18 +564,21 @@ func TestTrustedProxies(t *testing.T) {
|
|||||||
expected []string
|
expected []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
// The default must be "trust nobody": an empty list
|
|
||||||
// means forwarded headers are ignored, never that
|
|
||||||
// every peer may speak for the client.
|
|
||||||
name: caseUnsetUsesDefault,
|
name: caseUnsetUsesDefault,
|
||||||
set: false,
|
set: false,
|
||||||
expected: []string{},
|
expected: defaultProxies,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "blank value trusts nothing",
|
name: "blank value uses default",
|
||||||
set: true,
|
set: true,
|
||||||
value: " ",
|
value: " ",
|
||||||
expected: []string{},
|
expected: defaultProxies,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "set value replaces the default entirely",
|
||||||
|
set: true,
|
||||||
|
value: "203.0.113.7",
|
||||||
|
expected: []string{"203.0.113.7/32"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: caseValidValueParsed,
|
name: caseValidValueParsed,
|
||||||
@@ -845,101 +853,6 @@ func TestEgressAllowlistWarning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSharedRateLimitBucketWarning covers the startup warning that
|
|
||||||
// tells an operator a deployment behind a reverse proxy shares one
|
|
||||||
// rate-limit bucket between every client, which turns the receiver
|
|
||||||
// limits into service-wide ceilings and collapses login failure
|
|
||||||
// counting. It must fire whenever TRUSTED_PROXIES is empty, in any
|
|
||||||
// environment, because behind a proxy every client shares one bucket
|
|
||||||
// in dev and prod alike. It stays quiet once proxies are named.
|
|
||||||
func TestSharedRateLimitBucketWarning(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
environment string
|
|
||||||
trustedProxies string
|
|
||||||
expectWarning bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "prod without trusted proxies warns",
|
|
||||||
environment: config.EnvironmentProd,
|
|
||||||
expectWarning: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "prod with trusted proxies is quiet",
|
|
||||||
environment: config.EnvironmentProd,
|
|
||||||
trustedProxies: cidrPrivateV4,
|
|
||||||
expectWarning: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dev without trusted proxies warns",
|
|
||||||
environment: config.EnvironmentDev,
|
|
||||||
expectWarning: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dev with trusted proxies is quiet",
|
|
||||||
environment: config.EnvironmentDev,
|
|
||||||
trustedProxies: cidrPrivateV4,
|
|
||||||
expectWarning: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
|
||||||
// is incompatible with parallel subtests.
|
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", tt.environment)
|
|
||||||
|
|
||||||
if tt.trustedProxies == "" {
|
|
||||||
require.NoError(
|
|
||||||
t, os.Unsetenv("TRUSTED_PROXIES"),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
t.Setenv("TRUSTED_PROXIES", tt.trustedProxies)
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
log := slog.New(slog.NewJSONHandler(
|
|
||||||
&buf, &slog.HandlerOptions{
|
|
||||||
Level: slog.LevelDebug,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
config.WarnSharedRateLimitBucketForTest(log),
|
|
||||||
)
|
|
||||||
|
|
||||||
if !tt.expectWarning {
|
|
||||||
assert.Empty(t, buf.String())
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logged := buf.String()
|
|
||||||
|
|
||||||
assert.Contains(t, logged, `"level":"WARN"`)
|
|
||||||
assert.Contains(t, logged, "TRUSTED_PROXIES")
|
|
||||||
assert.Contains(t, logged, "share one bucket")
|
|
||||||
assert.Contains(
|
|
||||||
t, logged, "throttle every other client's failed logins",
|
|
||||||
)
|
|
||||||
// The warning must not claim a lockout the login
|
|
||||||
// endpoint no longer permits: credentials are verified
|
|
||||||
// before any budget is spent.
|
|
||||||
assert.Contains(
|
|
||||||
t, logged, "a correct password still gets in",
|
|
||||||
)
|
|
||||||
// The text must stay accurate for a developer with
|
|
||||||
// nothing in front of the process, where an empty
|
|
||||||
// list costs nothing.
|
|
||||||
assert.Contains(
|
|
||||||
t, logged, "nothing proxying to this process",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// metricsEnv describes what one subtest below puts in the
|
// metricsEnv describes what one subtest below puts in the
|
||||||
// environment for a single METRICS_ variable. A variable that is
|
// environment for a single METRICS_ variable. A variable that is
|
||||||
// set to the empty string and one that is not set at all are
|
// set to the empty string and one that is not set at all are
|
||||||
|
|||||||
@@ -6,21 +6,6 @@ import "log/slog"
|
|||||||
// the external config_test package so each helper can be covered by
|
// the external config_test package so each helper can be covered by
|
||||||
// its own table-driven test without weakening the package API.
|
// its own table-driven test without weakening the package API.
|
||||||
|
|
||||||
// WarnSharedRateLimitBucketForTest loads a Config from the current
|
|
||||||
// environment and emits its startup warnings to log. The real logger
|
|
||||||
// writes to stdout, so this lets the warning's firing condition be
|
|
||||||
// asserted against a handler the test controls.
|
|
||||||
func WarnSharedRateLimitBucketForTest(log *slog.Logger) error {
|
|
||||||
c, err := loadFromEnv()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.warnSharedRateLimitBucket(log)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WarnEgressAllowlistForTest loads a Config from the current
|
// WarnEgressAllowlistForTest loads a Config from the current
|
||||||
// environment and emits its egress-allowlist startup warning to
|
// environment and emits its egress-allowlist startup warning to
|
||||||
// log, so a test can assert both that the warning fires only when
|
// log, so a test can assert both that the warning fires only when
|
||||||
|
|||||||
@@ -93,11 +93,11 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
deliveries []database.Delivery
|
deliveries []database.Delivery
|
||||||
results []database.DeliveryResult
|
results []database.DeliveryResult
|
||||||
depths []struct{ Depth int }
|
depths []struct{ Depth int }
|
||||||
removed []database.TargetTotals
|
|
||||||
)
|
)
|
||||||
|
|
||||||
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
|
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
|
||||||
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
|
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
|
||||||
|
byAge := "idx_events_deleted_at_created_at (deleted_at=? AND created_at<?)"
|
||||||
|
|
||||||
// The delivery engine: recovery and the retry sweep, the sweep for
|
// The delivery engine: recovery and the retry sweep, the sweep for
|
||||||
// stranded pending deliveries, and the queue depth count.
|
// stranded pending deliveries, and the queue depth count.
|
||||||
@@ -123,89 +123,25 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
Order("attempt_num ASC").Find(&results),
|
Order("attempt_num ASC").Find(&results),
|
||||||
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
|
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
|
||||||
|
|
||||||
// Retention (reapExpired, deleteEvents): one batch of expired
|
// Retention's three deletes (reapExpired), whose subqueries are built
|
||||||
// events, then their attempts, deliveries and the events.
|
// afresh for each statement as it builds them.
|
||||||
var expired []string
|
expiredEventIDs := func() *gorm.DB {
|
||||||
|
return dry.Model(&database.Event{}).Select("id").
|
||||||
|
Where("created_at < ?", cutoff)
|
||||||
|
}
|
||||||
|
|
||||||
assertPlanUses(t, db, dry.Unscoped().Model(&database.Event{}).
|
|
||||||
Where("created_at < ?", cutoff).
|
|
||||||
Limit(database.ExportReapBatchSize).Pluck("id", &expired),
|
|
||||||
"idx_events_created_at (created_at<?)")
|
|
||||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||||
"delivery_id IN (?)", dry.Unscoped().Model(&database.Delivery{}).
|
"delivery_id IN (?)", dry.Model(&database.Delivery{}).
|
||||||
Select("id").Where("event_id IN ?", ids),
|
Select("id").Where("event_id IN (?)", expiredEventIDs()),
|
||||||
).Delete(&database.DeliveryResult{}),
|
).Delete(&database.DeliveryResult{}),
|
||||||
"idx_delivery_results_delivery_id (delivery_id=?)",
|
"idx_delivery_results_delivery_id (delivery_id=?)", byEvent, byAge)
|
||||||
"idx_deliveries_event_id (event_id=?)")
|
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||||
assertPlanUses(t, db, dry.Unscoped().Model(&database.Delivery{}).
|
"event_id IN (?)", expiredEventIDs(),
|
||||||
Select("target_id, count(*) AS deliveries_removed, "+
|
).Delete(&database.Delivery{}),
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
|
"idx_deliveries_event_id (event_id=?)", byAge)
|
||||||
database.DeliveryStatusFailed).
|
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||||
Where("event_id IN ?", ids).Group("target_id").Find(&removed),
|
"created_at < ?", cutoff,
|
||||||
"idx_deliveries_event_id (event_id=?)")
|
).Delete(&database.Event{}), "idx_events_created_at (created_at<?)")
|
||||||
assertPlanUses(t, db, dry.Unscoped().Where("event_id IN ?", ids).
|
|
||||||
Delete(&database.Delivery{}), "idx_deliveries_event_id (event_id=?)")
|
|
||||||
assertPlanUses(t, db, dry.Unscoped().Where("id IN ?", ids).
|
|
||||||
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
|
|
||||||
// page's statistics (readEventStats in the handlers): deliveries in
|
|
||||||
// progress, each target's deliveries finished since a time, which must
|
|
||||||
// come from the index alone, events received since a time, and the
|
|
||||||
// newest event, which must come straight off an index rather than from
|
|
||||||
// sorting every event.
|
|
||||||
func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
require.NoError(t, lc.Start(ctx))
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
|
||||||
|
|
||||||
db, err := mgr.GetDB(uuid.New().String())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
dry := db.Session(&gorm.Session{DryRun: true})
|
|
||||||
since := time.Now()
|
|
||||||
|
|
||||||
var (
|
|
||||||
count int64
|
|
||||||
newest []time.Time
|
|
||||||
byTarget []struct{ TargetID string }
|
|
||||||
)
|
|
||||||
|
|
||||||
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
|
|
||||||
Where("status IN ?", []database.DeliveryStatus{
|
|
||||||
database.DeliveryStatusPending,
|
|
||||||
database.DeliveryStatusRetrying,
|
|
||||||
}).Count(&count),
|
|
||||||
"idx_deliveries_status (status=? AND deleted_at=?)")
|
|
||||||
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
|
|
||||||
Select("target_id, "+
|
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
|
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS failed",
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
database.DeliveryStatusFailed).
|
|
||||||
Where("status IN ? AND finished_at >= ?",
|
|
||||||
[]database.DeliveryStatus{
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
database.DeliveryStatusFailed,
|
|
||||||
}, since).
|
|
||||||
Group("target_id").Find(&byTarget),
|
|
||||||
"COVERING INDEX idx_deliveries_status "+
|
|
||||||
"(status=? AND deleted_at=? AND finished_at>?)")
|
|
||||||
assertPlanUses(t, db, dry.Model(&database.Event{}).
|
|
||||||
Where("created_at >= ?", since).Count(&count),
|
|
||||||
"idx_events_deleted_at_created_at "+
|
|
||||||
"(deleted_at=? AND created_at>?)")
|
|
||||||
|
|
||||||
newestEvent := dry.Model(&database.Event{}).
|
|
||||||
Order("created_at DESC").Limit(1).Pluck("created_at", &newest)
|
|
||||||
assertPlanUses(t, db, newestEvent,
|
|
||||||
"idx_events_deleted_at_created_at (deleted_at=?)")
|
|
||||||
assert.NotContains(t, queryPlan(t, db, newestEvent), "TEMP B-TREE")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
||||||
@@ -216,18 +152,6 @@ func assertPlanUses(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
plan := queryPlan(t, db, built)
|
|
||||||
|
|
||||||
for _, index := range indexes {
|
|
||||||
assert.Contains(t, plan, index, built.Statement.SQL.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// queryPlan returns SQLite's plan for a statement GORM built in a dry
|
|
||||||
// run, run with the same SQL and arguments GORM would send.
|
|
||||||
func queryPlan(t *testing.T, db, built *gorm.DB) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var plan []struct{ Detail string }
|
var plan []struct{ Detail string }
|
||||||
|
|
||||||
require.NoError(t, db.Raw(
|
require.NoError(t, db.Raw(
|
||||||
@@ -235,5 +159,8 @@ func queryPlan(t *testing.T, db, built *gorm.DB) string {
|
|||||||
built.Statement.Vars...,
|
built.Statement.Vars...,
|
||||||
).Scan(&plan).Error)
|
).Scan(&plan).Error)
|
||||||
|
|
||||||
return fmt.Sprint(plan)
|
for _, index := range indexes {
|
||||||
|
assert.Contains(t, fmt.Sprint(plan), index,
|
||||||
|
built.Statement.SQL.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ func NewTestRetentionReaper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportReapBatchSize exposes how many expired events one retention
|
|
||||||
// transaction deletes.
|
|
||||||
const ExportReapBatchSize = reapBatchSize
|
|
||||||
|
|
||||||
// ExportSweep runs a single retention sweep synchronously for tests.
|
// ExportSweep runs a single retention sweep synchronously for tests.
|
||||||
func (r *RetentionReaper) ExportSweep(ctx context.Context) {
|
func (r *RetentionReaper) ExportSweep(ctx context.Context) {
|
||||||
r.sweep(ctx)
|
r.sweep(ctx)
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import "gorm.io/gorm"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DeliveryStatus represents the status of a delivery
|
// DeliveryStatus represents the status of a delivery
|
||||||
type DeliveryStatus string
|
type DeliveryStatus string
|
||||||
@@ -41,7 +37,7 @@ type Delivery struct {
|
|||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
|
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
|
||||||
TargetID string `gorm:"type:uuid;not null;index:idx_deliveries_status,priority:4" json:"targetId"`
|
TargetID string `gorm:"type:uuid;not null" json:"targetId"`
|
||||||
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
|
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
|
||||||
|
|
||||||
// DeletedAt repeats the BaseModel field only to be the second column
|
// DeletedAt repeats the BaseModel field only to be the second column
|
||||||
@@ -49,13 +45,6 @@ type Delivery struct {
|
|||||||
// gives.
|
// gives.
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
|
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
|
||||||
|
|
||||||
// FinishedAt is when the delivery became delivered or failed, and
|
|
||||||
// nil while it is pending or retrying. It and then TargetID end the
|
|
||||||
// status index, so the webhook page counts each target's deliveries
|
|
||||||
// that finished in a recent window by reading just that window from
|
|
||||||
// the index.
|
|
||||||
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Event Event `json:"event,omitzero"`
|
Event Event `json:"event,omitzero"`
|
||||||
Target Target `json:"target,omitzero"`
|
Target Target `json:"target,omitzero"`
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The running totals in a webhook's event database keep the webhook
|
|
||||||
// page's lifetime figures right after retention has removed the rows
|
|
||||||
// they count, and let the page show them without counting every row.
|
|
||||||
// Each total changes in the transaction that writes or deletes the
|
|
||||||
// rows it counts.
|
|
||||||
|
|
||||||
// EventTotals is the single row counting a webhook's events: every
|
|
||||||
// event ever stored, 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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName names the table AddEventTotals updates.
|
|
||||||
func (EventTotals) TableName() string {
|
|
||||||
return "event_totals"
|
|
||||||
}
|
|
||||||
|
|
||||||
// TargetTotals is one row per target counting its deliveries: every
|
|
||||||
// delivery ever created, how many became delivered and how many
|
|
||||||
// failed, and how many deliveries and failed deliveries retention has
|
|
||||||
// deleted. The webhook's delivery figures are these rows summed.
|
|
||||||
type TargetTotals struct {
|
|
||||||
TargetID string `gorm:"type:uuid;primaryKey"`
|
|
||||||
|
|
||||||
Deliveries int64 `gorm:"not null"`
|
|
||||||
Delivered int64 `gorm:"not null"`
|
|
||||||
Failed int64 `gorm:"not null"`
|
|
||||||
|
|
||||||
DeliveriesRemoved int64 `gorm:"not null"`
|
|
||||||
FailedRemoved int64 `gorm:"not null"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName names the table AddTargetTotals updates.
|
|
||||||
func (TargetTotals) TableName() string {
|
|
||||||
return "target_totals"
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddEventTotals adds each count in add to the webhook's event totals.
|
|
||||||
// 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 + ?`,
|
|
||||||
add.Events, add.EventsRemoved,
|
|
||||||
).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("adding to event totals: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddTargetTotals adds each count in add to the totals of the target
|
|
||||||
// add.TargetID names, creating its row the first time. Call it on the
|
|
||||||
// transaction that writes or deletes the deliveries it counts.
|
|
||||||
func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
|
|
||||||
err := tx.Exec(
|
|
||||||
`INSERT INTO target_totals (target_id, deliveries, delivered,
|
|
||||||
failed, deliveries_removed, failed_removed)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT (target_id) DO UPDATE SET
|
|
||||||
deliveries = deliveries + excluded.deliveries,
|
|
||||||
delivered = delivered + excluded.delivered,
|
|
||||||
failed = failed + excluded.failed,
|
|
||||||
deliveries_removed =
|
|
||||||
deliveries_removed + excluded.deliveries_removed,
|
|
||||||
failed_removed = failed_removed + excluded.failed_removed`,
|
|
||||||
add.TargetID, add.Deliveries, add.Delivered,
|
|
||||||
add.Failed, add.DeliveriesRemoved, add.FailedRemoved,
|
|
||||||
).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"adding to totals of target %s: %w", add.TargetID, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,7 @@ package database
|
|||||||
|
|
||||||
// Migrate runs database migrations for the main application database.
|
// Migrate runs database migrations for the main application database.
|
||||||
// Only configuration-tier models are stored in the main database.
|
// Only configuration-tier models are stored in the main database.
|
||||||
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
|
// Event-tier models (Event, Delivery, DeliveryResult) live in
|
||||||
// TargetTotals) live in
|
|
||||||
// per-webhook dedicated databases managed by WebhookDBManager.
|
// per-webhook dedicated databases managed by WebhookDBManager.
|
||||||
func (d *Database) Migrate() error {
|
func (d *Database) Migrate() error {
|
||||||
return d.db.AutoMigrate(
|
return d.db.AutoMigrate(
|
||||||
|
|||||||
@@ -18,13 +18,6 @@ import (
|
|||||||
// computation.
|
// computation.
|
||||||
const hoursPerDay = 24
|
const hoursPerDay = 24
|
||||||
|
|
||||||
// reapBatchSize is how many expired events one retention transaction
|
|
||||||
// deletes. A transaction holds the event database's write lock, which
|
|
||||||
// the receiver and the delivery workers wait for, so a large prune is
|
|
||||||
// split into transactions each short enough to finish well inside the
|
|
||||||
// busy timeout.
|
|
||||||
const reapBatchSize = 1000
|
|
||||||
|
|
||||||
// RetentionReaperParams holds the fx dependencies for the
|
// RetentionReaperParams holds the fx dependencies for the
|
||||||
// RetentionReaper.
|
// RetentionReaper.
|
||||||
type RetentionReaperParams struct {
|
type RetentionReaperParams struct {
|
||||||
@@ -272,97 +265,57 @@ func retentionCutoff(
|
|||||||
), true
|
), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// reapExpired hard-deletes the events older than cutoff, with their
|
// reapExpired hard-deletes, in foreign-key-safe order, the delivery
|
||||||
// deliveries and delivery results, reapBatchSize events per
|
// results, deliveries, and events associated with events older than
|
||||||
// transaction until none is left. It returns the number of events
|
// cutoff. Deletes are unscoped so rows are physically removed rather
|
||||||
|
// than soft-deleted, reclaiming disk. It returns the number of events
|
||||||
// deleted.
|
// deleted.
|
||||||
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
|
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
|
||||||
var total int64
|
// Fresh subqueries are built per statement to avoid reusing a
|
||||||
|
// mutated builder across executions.
|
||||||
for {
|
expiredEventIDs := func() *gorm.DB {
|
||||||
var eventIDs []string
|
return db.Model(&Event{}).
|
||||||
|
|
||||||
err := db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
err := tx.Unscoped().Model(&Event{}).
|
|
||||||
Where("created_at < ?", cutoff).
|
|
||||||
Limit(reapBatchSize).
|
|
||||||
Pluck("id", &eventIDs).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("selecting expired events: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(eventIDs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return deleteEvents(tx, eventIDs)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return total, err
|
|
||||||
}
|
|
||||||
|
|
||||||
total += int64(len(eventIDs))
|
|
||||||
|
|
||||||
if len(eventIDs) < reapBatchSize {
|
|
||||||
return total, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteEvents hard-deletes the given events and, in foreign-key-safe
|
|
||||||
// order before them, their delivery results and deliveries, then adds
|
|
||||||
// what it deleted to the running totals. It runs on reapExpired's
|
|
||||||
// transaction, so the totals change exactly when the rows do. Deletes
|
|
||||||
// are unscoped so rows are physically removed rather than
|
|
||||||
// soft-deleted, reclaiming disk.
|
|
||||||
func deleteEvents(tx *gorm.DB, eventIDs []string) error {
|
|
||||||
// 1. The delivery results of the events' deliveries.
|
|
||||||
err := tx.Unscoped().
|
|
||||||
Where("delivery_id IN (?)", tx.Unscoped().Model(&Delivery{}).
|
|
||||||
Select("id").
|
Select("id").
|
||||||
Where("event_id IN ?", eventIDs)).
|
Where("created_at < ?", cutoff)
|
||||||
Delete(&DeliveryResult{}).Error
|
}
|
||||||
if err != nil {
|
expiredDeliveryIDs := func() *gorm.DB {
|
||||||
return fmt.Errorf("deleting expired delivery results: %w", err)
|
return db.Model(&Delivery{}).
|
||||||
|
Select("id").
|
||||||
|
Where("event_id IN (?)", expiredEventIDs())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. The events' deliveries, after counting them, and the failed
|
// 1. Delivery results whose delivery belongs to an expired event.
|
||||||
// ones among them, per target. The status is tested in the select
|
res := db.Unscoped().
|
||||||
// list rather than the WHERE clause: there, SQLite would read every
|
Where("delivery_id IN (?)", expiredDeliveryIDs()).
|
||||||
// failed delivery the webhook has through the status index,
|
Delete(&DeliveryResult{})
|
||||||
// instead of only these through the event_id index.
|
if res.Error != nil {
|
||||||
var removed []TargetTotals
|
return 0, fmt.Errorf(
|
||||||
|
"deleting expired delivery results: %w",
|
||||||
err = tx.Unscoped().Model(&Delivery{}).
|
res.Error,
|
||||||
Select("target_id, count(*) AS deliveries_removed, "+
|
)
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
|
|
||||||
DeliveryStatusFailed).
|
|
||||||
Where("event_id IN ?", eventIDs).
|
|
||||||
Group("target_id").
|
|
||||||
Find(&removed).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("counting expired deliveries: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Unscoped().
|
// 2. Deliveries belonging to an expired event.
|
||||||
Where("event_id IN ?", eventIDs).
|
del := db.Unscoped().
|
||||||
Delete(&Delivery{}).Error
|
Where("event_id IN (?)", expiredEventIDs()).
|
||||||
if err != nil {
|
Delete(&Delivery{})
|
||||||
return fmt.Errorf("deleting expired deliveries: %w", err)
|
if del.Error != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"deleting expired deliveries: %w",
|
||||||
|
del.Error,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. The events themselves.
|
// 3. The expired events themselves.
|
||||||
ev := tx.Unscoped().Where("id IN ?", eventIDs).Delete(&Event{})
|
ev := db.Unscoped().
|
||||||
|
Where("created_at < ?", cutoff).
|
||||||
|
Delete(&Event{})
|
||||||
if ev.Error != nil {
|
if ev.Error != nil {
|
||||||
return fmt.Errorf("deleting expired events: %w", ev.Error)
|
return 0, fmt.Errorf(
|
||||||
|
"deleting expired events: %w",
|
||||||
|
ev.Error,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range removed {
|
return ev.RowsAffected, nil
|
||||||
err = AddTargetTotals(tx, removed[i])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return AddEventTotals(tx, EventTotals{EventsRemoved: ev.RowsAffected})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
package database_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// readEventTotals reads a webhook database's row of event totals,
|
|
||||||
// asserting that it has exactly one.
|
|
||||||
func readEventTotals(t *testing.T, db *gorm.DB) database.EventTotals {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var rows []database.EventTotals
|
|
||||||
|
|
||||||
require.NoError(t, db.Find(&rows).Error)
|
|
||||||
require.Len(t, rows, 1)
|
|
||||||
|
|
||||||
return rows[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// readTargetTotals reads a webhook database's target totals, keyed by
|
|
||||||
// target.
|
|
||||||
func readTargetTotals(
|
|
||||||
t *testing.T, db *gorm.DB,
|
|
||||||
) map[string]database.TargetTotals {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var rows []database.TargetTotals
|
|
||||||
|
|
||||||
require.NoError(t, db.Find(&rows).Error)
|
|
||||||
|
|
||||||
byTarget := make(map[string]database.TargetTotals, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
byTarget[row.TargetID] = row
|
|
||||||
}
|
|
||||||
|
|
||||||
return byTarget
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookDBManager_TotalsSurviveReopen verifies that a new event
|
|
||||||
// database starts with one row of zero event totals and no target
|
|
||||||
// totals, that adding to a target twice adds to the one row, and that
|
|
||||||
// opening the database again keeps everything added.
|
|
||||||
func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
require.NoError(t, lc.Start(ctx))
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
|
||||||
|
|
||||||
webhookID := uuid.New().String()
|
|
||||||
|
|
||||||
db, err := mgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
fresh := readEventTotals(t, db)
|
|
||||||
assert.Equal(t, database.EventTotals{ID: fresh.ID}, fresh)
|
|
||||||
assert.Empty(t, readTargetTotals(t, db))
|
|
||||||
|
|
||||||
first, second := uuid.New().String(), uuid.New().String()
|
|
||||||
|
|
||||||
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
|
||||||
Events: 2,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: first, Deliveries: 2, Delivered: 1,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: first, Failed: 1,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: second, Deliveries: 1,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Drop the cached connection so the next open reopens the file,
|
|
||||||
// as a restart would.
|
|
||||||
require.NoError(t, mgr.CloseAll())
|
|
||||||
|
|
||||||
db, err = mgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, database.EventTotals{ID: fresh.ID, Events: 2},
|
|
||||||
readEventTotals(t, db))
|
|
||||||
assert.Equal(t, map[string]database.TargetTotals{
|
|
||||||
first: {
|
|
||||||
TargetID: first, Deliveries: 2, Delivered: 1, Failed: 1,
|
|
||||||
},
|
|
||||||
second: {TargetID: second, Deliveries: 1},
|
|
||||||
}, readTargetTotals(t, db))
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedExpiredEvents stores count events created at the given time,
|
|
||||||
// each with a delivered delivery to one target and a failed delivery
|
|
||||||
// to the other, and one attempt for each delivery.
|
|
||||||
func seedExpiredEvents(
|
|
||||||
t *testing.T,
|
|
||||||
db *gorm.DB,
|
|
||||||
webhookID string,
|
|
||||||
count int,
|
|
||||||
createdAt time.Time,
|
|
||||||
delivered, failed string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
events := make([]database.Event, count)
|
|
||||||
deliveries := make([]database.Delivery, 0, 2*count)
|
|
||||||
|
|
||||||
for i := range events {
|
|
||||||
events[i] = database.Event{
|
|
||||||
WebhookID: webhookID,
|
|
||||||
EntrypointID: uuid.New().String(),
|
|
||||||
Method: http.MethodPost,
|
|
||||||
}
|
|
||||||
events[i].ID = uuid.New().String()
|
|
||||||
events[i].CreatedAt = createdAt
|
|
||||||
|
|
||||||
deliveries = append(deliveries,
|
|
||||||
database.Delivery{
|
|
||||||
EventID: events[i].ID,
|
|
||||||
TargetID: delivered,
|
|
||||||
Status: database.DeliveryStatusDelivered,
|
|
||||||
},
|
|
||||||
database.Delivery{
|
|
||||||
EventID: events[i].ID,
|
|
||||||
TargetID: failed,
|
|
||||||
Status: database.DeliveryStatusFailed,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
|
||||||
require.NoError(t, db.CreateInBatches(deliveries, 500).Error)
|
|
||||||
|
|
||||||
results := make([]database.DeliveryResult, len(deliveries))
|
|
||||||
for i := range deliveries {
|
|
||||||
results[i] = database.DeliveryResult{
|
|
||||||
DeliveryID: deliveries[i].ID, AttemptNum: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(results, 500).Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
|
|
||||||
// larger than one transaction's batch removes every expired event with
|
|
||||||
// its deliveries and delivery results, keeps the recent event, and
|
|
||||||
// adds what it removed to the event and target totals, so the totals
|
|
||||||
// within retention match the rows still stored.
|
|
||||||
func TestRetentionReaper_PrunesMoreThanOneBatch(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupRetentionTest(t)
|
|
||||||
|
|
||||||
webhookID := createWebhook(t, env.mainDB.DB(), 30)
|
|
||||||
|
|
||||||
db, err := env.mgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
expired := database.ExportReapBatchSize + 1
|
|
||||||
delivered, failed := uuid.New().String(), uuid.New().String()
|
|
||||||
seedExpiredEvents(t, db, webhookID, expired,
|
|
||||||
time.Now().Add(-40*24*time.Hour), delivered, failed)
|
|
||||||
|
|
||||||
// One recent event, delivered to the first target.
|
|
||||||
recent := seedEventChain(t, db, webhookID, time.Now())
|
|
||||||
require.NoError(t, db.Model(&database.Delivery{}).
|
|
||||||
Where("id = ?", recent.deliveryID).
|
|
||||||
Update("target_id", delivered).Error)
|
|
||||||
|
|
||||||
// The totals storing those rows would have left.
|
|
||||||
n := int64(expired)
|
|
||||||
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
|
||||||
Events: n + 1,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: failed, Deliveries: n, Failed: n,
|
|
||||||
}))
|
|
||||||
|
|
||||||
env.reaper.ExportSweep(context.Background())
|
|
||||||
|
|
||||||
// Only the recent event's rows are left.
|
|
||||||
for _, model := range []any{
|
|
||||||
&database.Event{}, &database.Delivery{}, &database.DeliveryResult{},
|
|
||||||
} {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
require.NoError(t, db.Model(model).Count(&count).Error)
|
|
||||||
assert.Equal(t, int64(1), count, "%T rows left", model)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertChainPresent(t, db, recent)
|
|
||||||
|
|
||||||
eventTotals := readEventTotals(t, db)
|
|
||||||
assert.Equal(t, database.EventTotals{
|
|
||||||
ID: eventTotals.ID, Events: n + 1, EventsRemoved: n,
|
|
||||||
}, eventTotals)
|
|
||||||
|
|
||||||
targetTotals := readTargetTotals(t, db)
|
|
||||||
assert.Equal(t, map[string]database.TargetTotals{
|
|
||||||
delivered: {
|
|
||||||
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
|
|
||||||
DeliveriesRemoved: n,
|
|
||||||
},
|
|
||||||
failed: {
|
|
||||||
TargetID: failed, Deliveries: n, Failed: n,
|
|
||||||
DeliveriesRemoved: n, FailedRemoved: n,
|
|
||||||
},
|
|
||||||
}, targetTotals)
|
|
||||||
|
|
||||||
// A sweep with nothing left to remove changes nothing.
|
|
||||||
env.reaper.ExportSweep(context.Background())
|
|
||||||
|
|
||||||
assert.Equal(t, eventTotals, readEventTotals(t, db))
|
|
||||||
assert.Equal(t, targetTotals, readTargetTotals(t, db))
|
|
||||||
}
|
|
||||||
@@ -35,8 +35,7 @@ var errInvalidCachedDBType = errors.New(
|
|||||||
|
|
||||||
// WebhookDBManager manages per-webhook SQLite database files
|
// WebhookDBManager manages per-webhook SQLite database files
|
||||||
// for event storage. Each webhook gets its own dedicated
|
// for event storage. Each webhook gets its own dedicated
|
||||||
// database containing Events, Deliveries, DeliveryResults and the
|
// database containing Events, Deliveries, and DeliveryResults.
|
||||||
// running totals of them (EventTotals, TargetTotals).
|
|
||||||
// Database connections are opened lazily and cached.
|
// Database connections are opened lazily and cached.
|
||||||
type WebhookDBManager struct {
|
type WebhookDBManager struct {
|
||||||
dataDir string
|
dataDir string
|
||||||
@@ -296,7 +295,6 @@ func (m *WebhookDBManager) openDB(
|
|||||||
// Run migrations for event-tier models only
|
// Run migrations for event-tier models only
|
||||||
err = db.AutoMigrate(
|
err = db.AutoMigrate(
|
||||||
&Event{}, &Delivery{}, &DeliveryResult{},
|
&Event{}, &Delivery{}, &DeliveryResult{},
|
||||||
&EventTotals{}, &TargetTotals{},
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
@@ -307,18 +305,6 @@ func (m *WebhookDBManager) openDB(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A new database gets its row of event totals, all zero. Target
|
|
||||||
// totals rows are created by the first delivery to each target.
|
|
||||||
err = db.FirstOrCreate(&EventTotals{}).Error
|
|
||||||
if err != nil {
|
|
||||||
_ = sqlDB.Close()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"creating event totals for webhook database %s: %w",
|
|
||||||
webhookID, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.log.Info(
|
m.log.Info(
|
||||||
"opened per-webhook database",
|
"opened per-webhook database",
|
||||||
"webhook_id", webhookID,
|
"webhook_id", webhookID,
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
package delivery_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// targetTotals reads one target's totals from a webhook database, all
|
|
||||||
// zero when it has no row.
|
|
||||||
func targetTotals(
|
|
||||||
t *testing.T, db *gorm.DB, targetID string,
|
|
||||||
) database.TargetTotals {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var rows []database.TargetTotals
|
|
||||||
|
|
||||||
require.NoError(t, db.Where("target_id = ?", targetID).
|
|
||||||
Find(&rows).Error)
|
|
||||||
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return database.TargetTotals{TargetID: targetID}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
|
|
||||||
// write records for the webhook page's statistics: the time a delivery
|
|
||||||
// finished, set only when it becomes delivered or failed, and one more
|
|
||||||
// on its target's delivered or failed total.
|
|
||||||
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
status database.DeliveryStatus
|
|
||||||
finished bool
|
|
||||||
delivered int64
|
|
||||||
failed int64
|
|
||||||
}{
|
|
||||||
{database.DeliveryStatusRetrying, false, 0, 0},
|
|
||||||
{database.DeliveryStatusDelivered, true, 1, 0},
|
|
||||||
{database.DeliveryStatusFailed, true, 0, 1},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(string(tt.status), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := testWebhookDB(t)
|
|
||||||
e := testEngine(t, 1)
|
|
||||||
event := seedEvent(t, db, `{}`)
|
|
||||||
targetID := uuid.New().String()
|
|
||||||
d := seedDelivery(
|
|
||||||
t, db, event.ID, targetID,
|
|
||||||
database.DeliveryStatusPending,
|
|
||||||
)
|
|
||||||
|
|
||||||
before := time.Now()
|
|
||||||
|
|
||||||
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
|
||||||
db, &d, tt.status,
|
|
||||||
))
|
|
||||||
|
|
||||||
var stored database.Delivery
|
|
||||||
|
|
||||||
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
|
|
||||||
assert.Equal(t, tt.status, stored.Status)
|
|
||||||
|
|
||||||
if tt.finished {
|
|
||||||
require.NotNil(t, stored.FinishedAt)
|
|
||||||
assert.False(t, stored.FinishedAt.Before(before))
|
|
||||||
} else {
|
|
||||||
assert.Nil(t, stored.FinishedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, database.TargetTotals{
|
|
||||||
TargetID: targetID,
|
|
||||||
Delivered: tt.delivered,
|
|
||||||
Failed: tt.failed,
|
|
||||||
}, targetTotals(t, db, targetID))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted covers a
|
|
||||||
// delivery retention deleted while the engine still held it. Failing
|
|
||||||
// it afterwards writes no row, so it adds no failure either: retention
|
|
||||||
// has already counted what it removed.
|
|
||||||
func TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := testWebhookDB(t)
|
|
||||||
e := testEngine(t, 1)
|
|
||||||
event := seedEvent(t, db, `{}`)
|
|
||||||
targetID := uuid.New().String()
|
|
||||||
d := seedDelivery(
|
|
||||||
t, db, event.ID, targetID,
|
|
||||||
database.DeliveryStatusRetrying,
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, db.Unscoped().
|
|
||||||
Delete(&database.Delivery{}, "id = ?", d.ID).Error)
|
|
||||||
|
|
||||||
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
|
||||||
db, &d, database.DeliveryStatusFailed,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert.Equal(t, database.TargetTotals{TargetID: targetID},
|
|
||||||
targetTotals(t, db, targetID))
|
|
||||||
}
|
|
||||||
@@ -1554,9 +1554,8 @@ func (e *Engine) updateDeliveryStatus(
|
|||||||
targetType database.TargetType,
|
targetType database.TargetType,
|
||||||
status database.DeliveryStatus,
|
status database.DeliveryStatus,
|
||||||
) error {
|
) error {
|
||||||
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
err := webhookDB.Model(d).
|
||||||
return writeDeliveryStatus(tx, d, status)
|
Update("status", status).Error
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"updating delivery %s to status %s: %w",
|
"updating delivery %s to status %s: %w",
|
||||||
@@ -1575,36 +1574,6 @@ func (e *Engine) updateDeliveryStatus(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeDeliveryStatus writes a delivery's new status. A delivery that
|
|
||||||
// becomes delivered or failed also gets the time it finished, and is
|
|
||||||
// added to its target's delivered or failed total. 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,
|
|
||||||
status database.DeliveryStatus,
|
|
||||||
) error {
|
|
||||||
if !status.Terminal() {
|
|
||||||
return tx.Model(d).Update("status", status).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
res := tx.Model(d).Updates(map[string]any{
|
|
||||||
"status": status,
|
|
||||||
"finished_at": time.Now(),
|
|
||||||
})
|
|
||||||
if res.Error != nil || res.RowsAffected == 0 {
|
|
||||||
return res.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
|
|
||||||
if status == database.DeliveryStatusFailed {
|
|
||||||
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
|
|
||||||
}
|
|
||||||
|
|
||||||
return database.AddTargetTotals(tx, add)
|
|
||||||
}
|
|
||||||
|
|
||||||
// settleStatus moves a delivery to its outcome status and reports a
|
// settleStatus moves a delivery to its outcome status and reports a
|
||||||
// failed write through bookkeepingFailed, which leaves the row
|
// failed write through bookkeepingFailed, which leaves the row
|
||||||
// recoverable. It exists so the target call sites read as one
|
// recoverable. It exists so the target call sites read as one
|
||||||
|
|||||||
@@ -57,10 +57,7 @@ func testWebhookDB(t *testing.T) *gorm.DB {
|
|||||||
&database.Event{},
|
&database.Event{},
|
||||||
&database.Delivery{},
|
&database.Delivery{},
|
||||||
&database.DeliveryResult{},
|
&database.DeliveryResult{},
|
||||||
&database.EventTotals{},
|
|
||||||
&database.TargetTotals{},
|
|
||||||
))
|
))
|
||||||
require.NoError(t, db.Create(&database.EventTotals{}).Error)
|
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,16 +150,6 @@ func (e *Engine) ExportDeliverSlack(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportUpdateDeliveryStatus exposes updateDeliveryStatus. It passes no
|
|
||||||
// target type, so no metric moves.
|
|
||||||
func (e *Engine) ExportUpdateDeliveryStatus(
|
|
||||||
webhookDB *gorm.DB,
|
|
||||||
d *database.Delivery,
|
|
||||||
status database.DeliveryStatus,
|
|
||||||
) error {
|
|
||||||
return e.updateDeliveryStatus(webhookDB, d, "", status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExportProcessNewTask exposes processNewTask.
|
// ExportProcessNewTask exposes processNewTask.
|
||||||
func (e *Engine) ExportProcessNewTask(
|
func (e *Engine) ExportProcessNewTask(
|
||||||
ctx context.Context, task *Task,
|
ctx context.Context, task *Task,
|
||||||
|
|||||||
@@ -103,9 +103,10 @@ func (h *Handlers) renderLoginError(
|
|||||||
// The credential check runs BEFORE any rate-limit budget is
|
// The credential check runs BEFORE any rate-limit budget is
|
||||||
// consulted, and only a failed check spends budget. That is what
|
// consulted, and only a failed check spends budget. That is what
|
||||||
// keeps the single administrative path reachable: behind the reverse
|
// keeps the single administrative path reachable: behind the reverse
|
||||||
// proxy this deployment requires, with TRUSTED_PROXIES unset, every
|
// proxy this deployment requires, when TRUSTED_PROXIES does not cover
|
||||||
// client shares one bucket, so a limiter spent on arrival lets any
|
// it, every client shares one bucket, so a limiter spent on arrival
|
||||||
// stranger deny the operator's own correct password indefinitely.
|
// lets any stranger deny the operator's own correct password
|
||||||
|
// indefinitely.
|
||||||
//
|
//
|
||||||
// Verifying first means every login POST costs an Argon2id hash, so
|
// Verifying first means every login POST costs an Argon2id hash, so
|
||||||
// the work is taken under a bounded number of verification slots.
|
// the work is taken under a bounded number of verification slots.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const (
|
|||||||
|
|
||||||
// sharedProxyPeer is the whole point of this file. Production is
|
// sharedProxyPeer is the whole point of this file. Production is
|
||||||
// required to run behind a TLS-terminating reverse proxy, and
|
// required to run behind a TLS-terminating reverse proxy, and
|
||||||
// TRUSTED_PROXIES defaults to empty, so every client — attacker
|
// when TRUSTED_PROXIES does not cover it every client — attacker
|
||||||
// and operator alike — reaches the process from the proxy's
|
// and operator alike — reaches the process from the proxy's
|
||||||
// address and shares one rate-limit bucket. Both parties in
|
// address and shares one rate-limit bucket. Both parties in
|
||||||
// these tests therefore use the same RemoteAddr.
|
// these tests therefore use the same RemoteAddr.
|
||||||
@@ -115,11 +115,11 @@ func floodFailures(
|
|||||||
// done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150.
|
// done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150.
|
||||||
//
|
//
|
||||||
// The attacker and the operator share one rate-limit bucket, because
|
// The attacker and the operator share one rate-limit bucket, because
|
||||||
// behind the mandated reverse proxy with TRUSTED_PROXIES unset every
|
// behind the mandated reverse proxy, when TRUSTED_PROXIES does not
|
||||||
// client keys on the proxy's address. The attacker floods the
|
// cover it, every client keys on the proxy's address. The attacker
|
||||||
// operator's own username — a single-admin product has a predictable
|
// floods the operator's own username — a single-admin product has a
|
||||||
// one — far past the failure limit. The operator must still be able
|
// predictable one — far past the failure limit. The operator must
|
||||||
// to log in with the correct password.
|
// still be able to log in with the correct password.
|
||||||
//
|
//
|
||||||
// This fails if credentials stop being verified ahead of the limiter.
|
// This fails if credentials stop being verified ahead of the limiter.
|
||||||
func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) {
|
func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) {
|
||||||
|
|||||||
@@ -299,9 +299,8 @@ func countInFlightDeliveries(
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// createReplayDelivery writes the new pending delivery row, adds it to
|
// createReplayDelivery writes the new pending delivery row and returns
|
||||||
// its target's totals in the same transaction, and returns the task
|
// the task that carries it to the delivery engine.
|
||||||
// that carries it to the delivery engine.
|
|
||||||
//
|
//
|
||||||
// The row is written with associations omitted, and neither Event nor
|
// The row is written with associations omitted, and neither Event nor
|
||||||
// Target is populated on it: GORM's SaveBeforeAssociations would
|
// Target is populated on it: GORM's SaveBeforeAssociations would
|
||||||
@@ -320,16 +319,7 @@ func createReplayDelivery(
|
|||||||
Status: database.DeliveryStatusPending,
|
Status: database.DeliveryStatusPending,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
err := webhookDB.Omit(clause.Associations).Create(dlv).Error
|
||||||
err := tx.Omit(clause.Associations).Create(dlv).Error
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return database.AddTargetTotals(tx, database.TargetTotals{
|
|
||||||
TargetID: dlv.TargetID, Deliveries: 1,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return delivery.Task{}, err
|
return delivery.Task{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,29 +69,6 @@ func (s *Handlers) LoadEventLogViewsForTest(
|
|||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebhookStatsForTest returns the figures the statistics pane on a
|
|
||||||
// webhook's page shows, from the webhook's entrypoints and targets
|
|
||||||
// loaded as that page loads them.
|
|
||||||
func (s *Handlers) WebhookStatsForTest(webhookID string) *WebhookStats {
|
|
||||||
var entrypoints []database.Entrypoint
|
|
||||||
|
|
||||||
s.db.DB().Where("webhook_id = ?", webhookID).Find(&entrypoints)
|
|
||||||
|
|
||||||
var targets []database.Target
|
|
||||||
|
|
||||||
s.db.DB().Where("webhook_id = ?", webhookID).Find(&targets)
|
|
||||||
|
|
||||||
return s.loadWebhookStats(webhookID, entrypoints, targets)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FinishedByTargetForTest exposes finishedByTarget for use in the
|
|
||||||
// handlers_test package.
|
|
||||||
func FinishedByTargetForTest(
|
|
||||||
webhookDB *gorm.DB, since time.Time,
|
|
||||||
) ([]TargetFinished, error) {
|
|
||||||
return finishedByTarget(webhookDB, since)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddTemplateForTest registers a template under a page name so that
|
// AddTemplateForTest registers a template under a page name so that
|
||||||
// the handlers_test package can drive the render path with a
|
// the handlers_test package can drive the render path with a
|
||||||
// template of its own.
|
// template of its own.
|
||||||
|
|||||||
@@ -91,22 +91,18 @@ type Handlers struct {
|
|||||||
|
|
||||||
// parsePageTemplate parses a page-specific template set from the
|
// parsePageTemplate parses a page-specific template set from the
|
||||||
// embedded FS. Each page template is combined with the shared
|
// embedded FS. Each page template is combined with the shared
|
||||||
// base, htmlheader, and navbar templates, and with any further files
|
// base, htmlheader, and navbar templates. The page file must be
|
||||||
// the page includes. The page file must be listed first so that its
|
// listed first so that its root action ({{template "base" .}})
|
||||||
// root action ({{template "base" .}}) becomes the template set's entry
|
// becomes the template set's entry point.
|
||||||
// point.
|
func parsePageTemplate(pageFile string) *template.Template {
|
||||||
func parsePageTemplate(
|
return template.Must(
|
||||||
pageFile string, included ...string,
|
template.ParseFS(
|
||||||
) *template.Template {
|
templates.Templates,
|
||||||
files := append([]string{
|
|
||||||
pageFile,
|
pageFile,
|
||||||
"base.html",
|
"base.html",
|
||||||
"htmlheader.html",
|
"htmlheader.html",
|
||||||
"navbar.html",
|
"navbar.html",
|
||||||
}, included...)
|
),
|
||||||
|
|
||||||
return template.Must(
|
|
||||||
template.ParseFS(templates.Templates, files...),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +131,7 @@ func New(
|
|||||||
"profile.html": parsePageTemplate("profile.html"),
|
"profile.html": parsePageTemplate("profile.html"),
|
||||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||||
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
"source_detail.html": parsePageTemplate("source_detail.html"),
|
||||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||||
"source_logs.html": parsePageTemplate("source_logs.html"),
|
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||||
|
|||||||
@@ -88,8 +88,6 @@ func TestHandleProfile_OwnProfile_OK(t *testing.T) {
|
|||||||
h.HandleProfile().ServeHTTP(w, req)
|
h.HandleProfile().ServeHTTP(w, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
assert.Contains(t, w.Body.String(), "Account Information")
|
|
||||||
assert.NotContains(t, w.Body.String(), "Account Type")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleProfile_OtherProfile_Forbidden(t *testing.T) {
|
func TestHandleProfile_OtherProfile_Forbidden(t *testing.T) {
|
||||||
|
|||||||
@@ -450,7 +450,6 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
"Targets": delivery.NewTargetViews(targets),
|
"Targets": delivery.NewTargetViews(targets),
|
||||||
"Events": events,
|
"Events": events,
|
||||||
"BaseURL": baseURL,
|
"BaseURL": baseURL,
|
||||||
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "source_detail.html", data)
|
h.renderTemplate(w, r, "source_detail.html", data)
|
||||||
|
|||||||
@@ -252,12 +252,11 @@ func requestEventSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createAndFanOut writes the event and one pending delivery per target,
|
// createAndFanOut writes the event and one pending delivery per target
|
||||||
// and adds them to the webhook's running totals, in a single
|
// in a single transaction, then hands the tasks to the delivery
|
||||||
// transaction, then hands the tasks to the delivery engine. It is the
|
// engine. It is the only path by which an event and its deliveries are
|
||||||
// only path by which an event and its deliveries are created, so a
|
// created, so a resubmitted event is retried, SSRF-guarded and
|
||||||
// resubmitted event is retried, SSRF-guarded and circuit-broken
|
// circuit-broken exactly as a received one is.
|
||||||
// exactly as a received one is.
|
|
||||||
//
|
//
|
||||||
// The tasks are returned as well as queued, so a caller can report how
|
// The tasks are returned as well as queued, so a caller can report how
|
||||||
// many targets the event went to.
|
// many targets the event went to.
|
||||||
@@ -297,13 +296,6 @@ func (h *Handlers) createAndFanOut(
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = database.AddEventTotals(tx, database.EventTotals{Events: 1})
|
|
||||||
if err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tx.Commit().Error
|
err = tx.Commit().Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf(
|
return nil, nil, fmt.Errorf(
|
||||||
@@ -362,9 +354,8 @@ func (h *Handlers) finishWebhookResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildDeliveryTasks creates one pending delivery per target in the
|
// buildDeliveryTasks creates one pending delivery per target in the
|
||||||
// transaction, adds each to its target's totals, and returns the tasks
|
// transaction and returns the tasks for the delivery engine. The
|
||||||
// for the delivery engine. The caller owns the transaction and rolls
|
// caller owns the transaction and rolls it back on error.
|
||||||
// it back on error.
|
|
||||||
func buildDeliveryTasks(
|
func buildDeliveryTasks(
|
||||||
tx *gorm.DB,
|
tx *gorm.DB,
|
||||||
event *database.Event,
|
event *database.Event,
|
||||||
@@ -388,13 +379,6 @@ func buildDeliveryTasks(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = database.AddTargetTotals(tx, database.TargetTotals{
|
|
||||||
TargetID: targets[i].ID, Deliveries: 1,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks = append(tasks, delivery.Task{
|
tasks = append(tasks, delivery.Task{
|
||||||
DeliveryID: dlv.ID,
|
DeliveryID: dlv.ID,
|
||||||
EventID: event.ID,
|
EventID: event.ID,
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The spans of the two recent windows the statistics pane reports on:
|
|
||||||
// the last 10 minutes and the last 24 hours.
|
|
||||||
const (
|
|
||||||
shortWindow = 10 * time.Minute
|
|
||||||
longWindow = 24 * time.Hour
|
|
||||||
)
|
|
||||||
|
|
||||||
// percent turns a fraction into a percentage.
|
|
||||||
const percent = 100
|
|
||||||
|
|
||||||
// WebhookStats holds the figures in the statistics pane at the top of
|
|
||||||
// the webhook page.
|
|
||||||
type WebhookStats struct {
|
|
||||||
Entrypoints int
|
|
||||||
ActiveEntrypoints int
|
|
||||||
Targets int
|
|
||||||
ActiveTargets int
|
|
||||||
|
|
||||||
// Lifetime counts every event, delivery and failure the webhook
|
|
||||||
// has had, and WithinRetention those still stored.
|
|
||||||
Lifetime Counts
|
|
||||||
WithinRetention Counts
|
|
||||||
|
|
||||||
// InProgress counts the deliveries still pending or retrying.
|
|
||||||
InProgress int64
|
|
||||||
|
|
||||||
// LastEventAt is when the newest stored event arrived, or nil when
|
|
||||||
// none is stored.
|
|
||||||
LastEventAt *time.Time
|
|
||||||
|
|
||||||
Last10Minutes RecentWindow
|
|
||||||
Last24Hours RecentWindow
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counts holds a number of events, of deliveries and of failed
|
|
||||||
// deliveries.
|
|
||||||
type Counts struct {
|
|
||||||
Events int64
|
|
||||||
Deliveries int64
|
|
||||||
Failures int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecentWindow holds what happened in one recent window: the events
|
|
||||||
// received in it, and the deliveries that became delivered or failed in
|
|
||||||
// it.
|
|
||||||
type RecentWindow struct {
|
|
||||||
Events int64
|
|
||||||
Delivered int64
|
|
||||||
Failed int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// TargetFinished is how many of one target's deliveries became
|
|
||||||
// delivered, and how many failed, in a recent window.
|
|
||||||
type TargetFinished struct {
|
|
||||||
TargetID string
|
|
||||||
Delivered int64
|
|
||||||
Failed int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// FailurePercent is the share of the deliveries finished in the window
|
|
||||||
// that failed, or a dash when none finished. Deliveries still pending
|
|
||||||
// or retrying are not counted either way.
|
|
||||||
func (w RecentWindow) FailurePercent() string {
|
|
||||||
finished := w.Delivered + w.Failed
|
|
||||||
if finished == 0 {
|
|
||||||
return "—"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"%.1f%%", percent*float64(w.Failed)/float64(finished),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadWebhookStats gathers the figures for the statistics pane from the
|
|
||||||
// webhook's entrypoints and targets, as the page has already loaded
|
|
||||||
// them, and from its event database. It returns nil, and logs why, when
|
|
||||||
// the event database cannot be read.
|
|
||||||
func (h *Handlers) loadWebhookStats(
|
|
||||||
webhookID string,
|
|
||||||
entrypoints []database.Entrypoint,
|
|
||||||
targets []database.Target,
|
|
||||||
) *WebhookStats {
|
|
||||||
stats := &WebhookStats{
|
|
||||||
Entrypoints: len(entrypoints),
|
|
||||||
Targets: len(targets),
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range entrypoints {
|
|
||||||
if entrypoints[i].Active {
|
|
||||||
stats.ActiveEntrypoints++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range targets {
|
|
||||||
if targets[i].Active {
|
|
||||||
stats.ActiveTargets++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Opening an event database that does not exist would create it,
|
|
||||||
// and it would hold nothing to count.
|
|
||||||
if !h.dbMgr.DBExists(webhookID) {
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhookID)
|
|
||||||
if err == nil {
|
|
||||||
err = readEventStats(webhookDB, time.Now(), stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
h.log.Error(
|
|
||||||
"failed to read webhook statistics",
|
|
||||||
"webhook_id", webhookID,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
// readEventStats fills in the figures that come from the webhook's
|
|
||||||
// event database. None of them reads every stored row: the totals are
|
|
||||||
// one row for the events and one per target for the deliveries, and
|
|
||||||
// every other figure is read from an index, over only the rows it
|
|
||||||
// counts.
|
|
||||||
func readEventStats(
|
|
||||||
db *gorm.DB, now time.Time, stats *WebhookStats,
|
|
||||||
) error {
|
|
||||||
err := readTotals(db, stats)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.Model(&database.Delivery{}).
|
|
||||||
Where("status IN ?", []database.DeliveryStatus{
|
|
||||||
database.DeliveryStatusPending,
|
|
||||||
database.DeliveryStatusRetrying,
|
|
||||||
}).
|
|
||||||
Count(&stats.InProgress).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("counting deliveries in progress: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var newest []time.Time
|
|
||||||
|
|
||||||
err = db.Model(&database.Event{}).
|
|
||||||
Order("created_at DESC").
|
|
||||||
Limit(1).
|
|
||||||
Pluck("created_at", &newest).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading newest event time: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(newest) > 0 {
|
|
||||||
stats.LastEventAt = &newest[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.Last10Minutes, err = readRecentWindow(
|
|
||||||
db, now.Add(-shortWindow),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.Last24Hours, err = readRecentWindow(
|
|
||||||
db, now.Add(-longWindow),
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
err := db.Take(&events).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading event totals: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var targets []database.TargetTotals
|
|
||||||
|
|
||||||
err = db.Find(&targets).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading target totals: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.Lifetime.Events = events.Events
|
|
||||||
stats.WithinRetention.Events = events.Events - events.EventsRemoved
|
|
||||||
|
|
||||||
for _, t := range targets {
|
|
||||||
stats.Lifetime.Deliveries += t.Deliveries
|
|
||||||
stats.Lifetime.Failures += t.Failed
|
|
||||||
stats.WithinRetention.Deliveries += t.Deliveries - t.DeliveriesRemoved
|
|
||||||
stats.WithinRetention.Failures += t.Failed - t.FailedRemoved
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// readRecentWindow counts the events received, and the deliveries that
|
|
||||||
// became delivered or failed, since the given time.
|
|
||||||
func readRecentWindow(
|
|
||||||
db *gorm.DB, since time.Time,
|
|
||||||
) (RecentWindow, error) {
|
|
||||||
var w RecentWindow
|
|
||||||
|
|
||||||
err := db.Model(&database.Event{}).
|
|
||||||
Where("created_at >= ?", since).
|
|
||||||
Count(&w.Events).Error
|
|
||||||
if err != nil {
|
|
||||||
return w, fmt.Errorf("counting recent events: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
byTarget, err := finishedByTarget(db, since)
|
|
||||||
if err != nil {
|
|
||||||
return w, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, f := range byTarget {
|
|
||||||
w.Delivered += f.Delivered
|
|
||||||
w.Failed += f.Failed
|
|
||||||
}
|
|
||||||
|
|
||||||
return w, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// finishedByTarget counts, for each target, the deliveries that became
|
|
||||||
// delivered and those that failed since the given time, in one query
|
|
||||||
// over just that window of the deliveries' status index. A target with
|
|
||||||
// neither is left out.
|
|
||||||
func finishedByTarget(
|
|
||||||
db *gorm.DB, since time.Time,
|
|
||||||
) ([]TargetFinished, error) {
|
|
||||||
var byTarget []TargetFinished
|
|
||||||
|
|
||||||
err := db.Model(&database.Delivery{}).
|
|
||||||
Select("target_id, "+
|
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
|
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS failed",
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
database.DeliveryStatusFailed).
|
|
||||||
Where("status IN ? AND finished_at >= ?",
|
|
||||||
[]database.DeliveryStatus{
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
database.DeliveryStatusFailed,
|
|
||||||
}, since).
|
|
||||||
Group("target_id").
|
|
||||||
Find(&byTarget).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"counting deliveries finished by target: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return byTarget, nil
|
|
||||||
}
|
|
||||||
@@ -1,442 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.uber.org/fx/fxtest"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// statsEntrypoint adds an entrypoint to a webhook and returns its path.
|
|
||||||
func statsEntrypoint(
|
|
||||||
t *testing.T, db *database.Database, webhookID string, active bool,
|
|
||||||
) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ep := &database.Entrypoint{
|
|
||||||
WebhookID: webhookID,
|
|
||||||
Path: uuid.New().String(),
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(ep).Error)
|
|
||||||
require.NoError(t, db.DB().Model(ep).Update("active", active).Error)
|
|
||||||
|
|
||||||
return ep.Path
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsDelivery returns an event's delivery to a target.
|
|
||||||
func statsDelivery(
|
|
||||||
t *testing.T, webhookDB *gorm.DB, eventID, targetID string,
|
|
||||||
) database.Delivery {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var d database.Delivery
|
|
||||||
|
|
||||||
require.NoError(t, webhookDB.Where(
|
|
||||||
"event_id = ? AND target_id = ?", eventID, targetID,
|
|
||||||
).First(&d).Error)
|
|
||||||
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsFinish settles a delivery as the delivery engine does: its
|
|
||||||
// final status and the time it finished, and one more on its target's
|
|
||||||
// delivered or failed total, in one transaction.
|
|
||||||
func statsFinish(
|
|
||||||
t *testing.T,
|
|
||||||
webhookDB *gorm.DB,
|
|
||||||
d database.Delivery,
|
|
||||||
status database.DeliveryStatus,
|
|
||||||
at time.Time,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
|
|
||||||
if status == database.DeliveryStatusFailed {
|
|
||||||
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, webhookDB.Transaction(func(tx *gorm.DB) error {
|
|
||||||
err := tx.Model(&database.Delivery{}).
|
|
||||||
Where("id = ?", d.ID).
|
|
||||||
Updates(map[string]any{"status": status, "finished_at": at}).
|
|
||||||
Error
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return database.AddTargetTotals(tx, add)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsAge moves an event's arrival back to the given time.
|
|
||||||
func statsAge(
|
|
||||||
t *testing.T, webhookDB *gorm.DB, eventID string, at time.Time,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
require.NoError(t, webhookDB.Model(&database.Event{}).
|
|
||||||
Where("id = ?", eventID).
|
|
||||||
Update("created_at", at).Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsTargetTotals reads a webhook database's target totals, keyed by
|
|
||||||
// target.
|
|
||||||
func statsTargetTotals(
|
|
||||||
t *testing.T, webhookDB *gorm.DB,
|
|
||||||
) map[string]database.TargetTotals {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var rows []database.TargetTotals
|
|
||||||
|
|
||||||
require.NoError(t, webhookDB.Find(&rows).Error)
|
|
||||||
|
|
||||||
byTarget := make(map[string]database.TargetTotals, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
byTarget[row.TargetID] = row
|
|
||||||
}
|
|
||||||
|
|
||||||
return byTarget
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsHistory is the webhook seedStatsHistory builds: its event
|
|
||||||
// database, its newest event, and its two active targets.
|
|
||||||
type statsHistory struct {
|
|
||||||
webhook *database.Webhook
|
|
||||||
webhookDB *gorm.DB
|
|
||||||
newest database.Event
|
|
||||||
first, second string
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedStatsHistory builds the webhook the statistics test checks: one
|
|
||||||
// day of retention, two entrypoints (one inactive) and three targets
|
|
||||||
// (one inactive). Three events arrive through the receiver, and so
|
|
||||||
// each has a delivery to the two active targets. The oldest event is
|
|
||||||
// past retention, the middle one six hours old, the newest just in.
|
|
||||||
// Their deliveries are settled as the delivery engine would, and a
|
|
||||||
// replay adds a pending delivery to the oldest event.
|
|
||||||
func seedStatsHistory(
|
|
||||||
t *testing.T,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
sess *session.Session,
|
|
||||||
db *database.Database,
|
|
||||||
dbMgr *database.WebhookDBManager,
|
|
||||||
) statsHistory {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
wh := &database.Webhook{
|
|
||||||
UserID: deleteTestUserID, Name: "stats", RetentionDays: 1,
|
|
||||||
}
|
|
||||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
||||||
|
|
||||||
path := statsEntrypoint(t, db, wh.ID, true)
|
|
||||||
statsEntrypoint(t, db, wh.ID, false)
|
|
||||||
|
|
||||||
first := seedConfiguredTarget(
|
|
||||||
t, db, wh.ID, database.TargetTypeHTTP,
|
|
||||||
`{"url":"`+replayTargetURL+`"}`,
|
|
||||||
)
|
|
||||||
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
||||||
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
||||||
require.NoError(t, db.DB().Model(inactive).
|
|
||||||
Update("active", false).Error)
|
|
||||||
|
|
||||||
router := receiverRouter(h)
|
|
||||||
|
|
||||||
for range 3 {
|
|
||||||
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
|
|
||||||
}
|
|
||||||
|
|
||||||
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
events := listEvents(t, webhookDB)
|
|
||||||
require.Len(t, events, 3)
|
|
||||||
|
|
||||||
oldest, middle, newest := events[0], events[1], events[2]
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
statsAge(t, webhookDB, oldest.ID, now.Add(-50*time.Hour))
|
|
||||||
statsAge(t, webhookDB, middle.ID, now.Add(-6*time.Hour))
|
|
||||||
|
|
||||||
oldestFailure := statsDelivery(t, webhookDB, oldest.ID, first.ID)
|
|
||||||
statsFinish(t, webhookDB, oldestFailure,
|
|
||||||
database.DeliveryStatusFailed, now.Add(-49*time.Hour))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, oldest.ID, second.ID),
|
|
||||||
database.DeliveryStatusDelivered, now.Add(-49*time.Hour))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, middle.ID, first.ID),
|
|
||||||
database.DeliveryStatusFailed, now.Add(-5*time.Hour))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, middle.ID, second.ID),
|
|
||||||
database.DeliveryStatusFailed, now.Add(-time.Minute))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, newest.ID, first.ID),
|
|
||||||
database.DeliveryStatusDelivered, now.Add(-2*time.Minute))
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther,
|
|
||||||
postReplay(t, h, sess, wh.ID, oldestFailure.ID).Code)
|
|
||||||
|
|
||||||
return statsHistory{
|
|
||||||
webhook: wh,
|
|
||||||
webhookDB: webhookDB,
|
|
||||||
newest: newest,
|
|
||||||
first: first.ID,
|
|
||||||
second: second.ID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsPrune runs the real retention reaper until it has removed one
|
|
||||||
// event from the webhook's database, then stops it.
|
|
||||||
func statsPrune(
|
|
||||||
t *testing.T,
|
|
||||||
db *database.Database,
|
|
||||||
dbMgr *database.WebhookDBManager,
|
|
||||||
log *logger.Logger,
|
|
||||||
webhookDB *gorm.DB,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
lc := fxtest.NewLifecycle(t)
|
|
||||||
database.NewRetentionReaper(lc, database.RetentionReaperParams{
|
|
||||||
Config: &config.Config{
|
|
||||||
RetentionSweepInterval: 10 * time.Millisecond,
|
|
||||||
},
|
|
||||||
Database: db,
|
|
||||||
DBManager: dbMgr,
|
|
||||||
Logger: log,
|
|
||||||
})
|
|
||||||
|
|
||||||
lc.RequireStart()
|
|
||||||
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
var totals database.EventTotals
|
|
||||||
|
|
||||||
err := webhookDB.Take(&totals).Error
|
|
||||||
|
|
||||||
return err == nil && totals.EventsRemoved == 1
|
|
||||||
}, 10*time.Second, 10*time.Millisecond)
|
|
||||||
|
|
||||||
lc.RequireStop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsPane returns the 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()
|
|
||||||
|
|
||||||
_, pane, found := strings.Cut(page, ">Statistics</h2>")
|
|
||||||
require.True(t, found, "the page has no statistics pane")
|
|
||||||
|
|
||||||
pane, _, _ = strings.Cut(pane, "<h2")
|
|
||||||
|
|
||||||
return 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 three deliveries and the replay, the
|
|
||||||
// second three; the inactive target has 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: 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure
|
|
||||||
// the statistics pane shows for the history seedStatsHistory builds,
|
|
||||||
// and each target's totals and recent figures, before and after the
|
|
||||||
// real retention reaper removes the oldest event.
|
|
||||||
func TestWebhookStats_EveryFigureAcrossRetentionPrune(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
log *logger.Logger
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
hist := seedStatsHistory(t, h, sess, db, dbMgr)
|
|
||||||
first, second := hist.first, hist.second
|
|
||||||
|
|
||||||
stats := h.WebhookStatsForTest(hist.webhook.ID)
|
|
||||||
require.NotNil(t, stats)
|
|
||||||
|
|
||||||
assert.Equal(t, 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: 3, Deliveries: 7, Failures: 3},
|
|
||||||
stats.Lifetime)
|
|
||||||
assert.Equal(t, stats.Lifetime, stats.WithinRetention)
|
|
||||||
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: 1, Delivered: 1, Failed: 1,
|
|
||||||
}, stats.Last10Minutes)
|
|
||||||
assert.Equal(t, handlers.RecentWindow{
|
|
||||||
Events: 2, Delivered: 1, Failed: 2,
|
|
||||||
}, stats.Last24Hours)
|
|
||||||
assert.Equal(t, "50.0%", stats.Last10Minutes.FailurePercent())
|
|
||||||
assert.Equal(t, "66.7%", stats.Last24Hours.FailurePercent())
|
|
||||||
|
|
||||||
assertStatsTargets(t, hist)
|
|
||||||
|
|
||||||
// Retention removes the oldest event with its three deliveries:
|
|
||||||
// the first target's failed one and the pending replay, and the
|
|
||||||
// second target's 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: 2, Deliveries: 4, Failures: 2},
|
|
||||||
after.WithinRetention)
|
|
||||||
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: 4, Delivered: 1, Failed: 2,
|
|
||||||
DeliveriesRemoved: 2, FailedRemoved: 1,
|
|
||||||
},
|
|
||||||
second: {
|
|
||||||
TargetID: second, Deliveries: 3, Delivered: 1, Failed: 1,
|
|
||||||
DeliveriesRemoved: 1,
|
|
||||||
},
|
|
||||||
}, statsTargetTotals(t, hist.webhookDB))
|
|
||||||
|
|
||||||
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
|
|
||||||
// pane itself, not only the line at the foot of the page, shows the
|
|
||||||
// webhook's retention period, for a finite one and for forever.
|
|
||||||
func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
retentionDays int
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{30, "30 days"},
|
|
||||||
{database.RetentionForeverDays, "forever"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
wh := &database.Webhook{
|
|
||||||
UserID: deleteTestUserID,
|
|
||||||
Name: "retention",
|
|
||||||
RetentionDays: tt.retentionDays,
|
|
||||||
}
|
|
||||||
require.NoError(t,
|
|
||||||
db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
||||||
|
|
||||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
||||||
assert.Contains(t, pane, "Retention", tt.want)
|
|
||||||
assert.Contains(t, pane, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookStats_WebhookWithNoEvents covers a webhook whose event
|
|
||||||
// database has never been opened: every count is zero, the
|
|
||||||
// percentages are a dash, and showing the page does not create the
|
|
||||||
// database.
|
|
||||||
func TestWebhookStats_WebhookWithNoEvents(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
|
|
||||||
assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID))
|
|
||||||
assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent())
|
|
||||||
|
|
||||||
statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
||||||
assert.False(t, dbMgr.DBExists(wh.ID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRecentWindow_FailurePercent pins the percentage: failed
|
|
||||||
// deliveries out of all that finished in the window.
|
|
||||||
func TestRecentWindow_FailurePercent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
window handlers.RecentWindow
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{handlers.RecentWindow{}, "—"},
|
|
||||||
{handlers.RecentWindow{Events: 4}, "—"},
|
|
||||||
{handlers.RecentWindow{Delivered: 3, Failed: 1}, "25.0%"},
|
|
||||||
{handlers.RecentWindow{Failed: 2}, "100.0%"},
|
|
||||||
{handlers.RecentWindow{Delivered: 2}, "0.0%"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
assert.Equal(t, tt.want, tt.window.FailurePercent(), tt.window)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -108,10 +108,10 @@ type failureWindow struct {
|
|||||||
//
|
//
|
||||||
// A limiter that spends budget on arrival cannot protect a
|
// A limiter that spends budget on arrival cannot protect a
|
||||||
// single-admin product: behind the reverse proxy the deployment
|
// single-admin product: behind the reverse proxy the deployment
|
||||||
// requires, with TRUSTED_PROXIES unset, every client keys on the
|
// requires, when TRUSTED_PROXIES does not cover it, every client
|
||||||
// proxy, so a stranger trickling five POSTs a minute keeps the one
|
// keys on the proxy, so a stranger trickling five POSTs a minute
|
||||||
// bucket full and the operator's own correct password is answered 429
|
// keeps the one bucket full and the operator's own correct password
|
||||||
// forever. There is no second administrative path.
|
// is answered 429 forever. There is no second administrative path.
|
||||||
//
|
//
|
||||||
// So budget is spent only by a FAILED verification. A correct
|
// So budget is spent only by a FAILED verification. A correct
|
||||||
// password is never throttled, whatever the counters say, which is
|
// password is never throttled, whatever the counters say, which is
|
||||||
|
|||||||
@@ -123,9 +123,8 @@ func bucketKey(addr netip.Addr) string {
|
|||||||
return prefix.String()
|
return prefix.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// isTrustedProxy reports whether addr belongs to a network the
|
// isTrustedProxy reports whether addr belongs to a network in
|
||||||
// operator listed in TRUSTED_PROXIES. The list is empty by default,
|
// TRUSTED_PROXIES, which by default is the RFC 1918 private ranges.
|
||||||
// so by default nothing is trusted.
|
|
||||||
func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
|
func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
|
||||||
for _, prefix := range m.params.Config.TrustedProxies {
|
for _, prefix := range m.params.Config.TrustedProxies {
|
||||||
if prefix.Contains(addr) {
|
if prefix.Contains(addr) {
|
||||||
|
|||||||
@@ -384,8 +384,8 @@ const (
|
|||||||
// trustedProxyCIDR is the proxy network the forwarded-path
|
// trustedProxyCIDR is the proxy network the forwarded-path
|
||||||
// tests configure, and trustedPeer an address inside it. A
|
// tests configure, and trustedPeer an address inside it. A
|
||||||
// production deployment is required to run behind a reverse
|
// production deployment is required to run behind a reverse
|
||||||
// proxy with TRUSTED_PROXIES set, so this is the shape the
|
// proxy that TRUSTED_PROXIES covers, either by the default or by
|
||||||
// bucketing has to hold in.
|
// a set value, so this is the shape the bucketing has to hold in.
|
||||||
trustedProxyCIDR = "10.0.0.0/8"
|
trustedProxyCIDR = "10.0.0.0/8"
|
||||||
trustedPeer = "10.0.0.1:44444"
|
trustedPeer = "10.0.0.1:44444"
|
||||||
)
|
)
|
||||||
@@ -426,8 +426,8 @@ func assertSharedBucket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestRateLimitKey_SpoofedForwardedFromUntrustedPeer is the test
|
// TestRateLimitKey_SpoofedForwardedFromUntrustedPeer is the test
|
||||||
// this gating exists for: with no trusted proxies configured (the
|
// this gating exists for: from a peer that is not a trusted
|
||||||
// default), a client that rotates a forwarded header on every
|
// proxy, a client that rotates a forwarded header on every
|
||||||
// request must stay in one bucket. If forwarded headers were
|
// request must stay in one bucket. If forwarded headers were
|
||||||
// trusted unconditionally, each spoofed value would mint a fresh
|
// trusted unconditionally, each spoofed value would mint a fresh
|
||||||
// bucket and the limit would stop no one.
|
// bucket and the limit would stop no one.
|
||||||
@@ -1097,8 +1097,9 @@ func TestPostRateLimit_IPv4IndependentPerAddress(t *testing.T) {
|
|||||||
// that arrives from trustedPeer — a configured trusted proxy — and
|
// that arrives from trustedPeer — a configured trusted proxy — and
|
||||||
// names forwarded as its client in X-Forwarded-For. That is the
|
// names forwarded as its client in X-Forwarded-For. That is the
|
||||||
// production path: a deployment is required to run behind a reverse
|
// production path: a deployment is required to run behind a reverse
|
||||||
// proxy with TRUSTED_PROXIES set, so the forwarded address, not the
|
// proxy that TRUSTED_PROXIES covers, either by the default or by a
|
||||||
// peer, is what the limiters bucket on there.
|
// set value, so the forwarded address, not the peer, is what the
|
||||||
|
// limiters bucket on there.
|
||||||
func forwardedKeyFor(
|
func forwardedKeyFor(
|
||||||
t *testing.T, m *middleware.Middleware, forwarded string,
|
t *testing.T, m *middleware.Middleware, forwarded string,
|
||||||
) string {
|
) string {
|
||||||
@@ -1178,9 +1179,9 @@ func TestRateLimitKey_ForwardedIPv6BucketsByPrefix(t *testing.T) {
|
|||||||
//
|
//
|
||||||
// Every existing test of this fallback uses an IPv4 proxy, where
|
// Every existing test of this fallback uses an IPv4 proxy, where
|
||||||
// bucketKey is the identity function, so replacing the call with
|
// bucketKey is the identity function, so replacing the call with
|
||||||
// peer.String() leaves the whole suite green. Only operator-listed
|
// peer.String() leaves the whole suite green. Only addresses inside
|
||||||
// addresses reach this line and the fallback is fail-closed, so this
|
// TRUSTED_PROXIES reach this line and the fallback is fail-closed, so
|
||||||
// pins behaviour rather than fixing a defect.
|
// this pins behaviour rather than fixing a defect.
|
||||||
func TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer(
|
func TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -154,11 +154,12 @@ func (s *Server) setupPageRoutes() {
|
|||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
|
|
||||||
// The login POST carries no pre-emptive rate limiter. Behind
|
// The login POST carries no pre-emptive rate limiter. Behind
|
||||||
// the reverse proxy production requires, with TRUSTED_PROXIES
|
// the reverse proxy production requires, when TRUSTED_PROXIES
|
||||||
// unset, every client shares one bucket, so a limiter spent
|
// does not cover it, every client shares one bucket, so a
|
||||||
// on arrival lets any stranger deny the operator the only
|
// limiter spent on arrival lets any stranger deny the operator
|
||||||
// administrative path. The handler verifies credentials first
|
// the only administrative path. The handler verifies
|
||||||
// and charges only failures; see Handlers.authenticateUser.
|
// credentials first and charges only failures; see
|
||||||
|
// Handlers.authenticateUser.
|
||||||
r.Get("/login", s.h.HandleLoginPage())
|
r.Get("/login", s.h.HandleLoginPage())
|
||||||
r.Post("/login", s.h.HandleLoginSubmit())
|
r.Post("/login", s.h.HandleLoginSubmit())
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,10 @@
|
|||||||
<dt class="w-32 text-sm font-medium text-gray-500">Username</dt>
|
<dt class="w-32 text-sm font-medium text-gray-500">Username</dt>
|
||||||
<dd class="text-sm text-gray-900">{{.User.Username}}</dd>
|
<dd class="text-sm text-gray-900">{{.User.Username}}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<dt class="w-32 text-sm font-medium text-gray-500">Account Type</dt>
|
||||||
|
<dd class="text-sm text-gray-900">Standard User</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,8 +24,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{template "webhook_stats" .}}
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Entrypoints -->
|
<!-- Entrypoints -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
{{define "webhook_stats"}}
|
|
||||||
<!-- Statistics pane at the top of the webhook page. -->
|
|
||||||
<div class="card mb-6">
|
|
||||||
<div class="p-4 border-b border-gray-200">
|
|
||||||
<h2 class="text-lg font-medium text-gray-900">Statistics</h2>
|
|
||||||
</div>
|
|
||||||
{{with .Stats}}
|
|
||||||
<div class="p-4 flex flex-wrap gap-6 text-sm border-b border-gray-200">
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Entrypoints</span>
|
|
||||||
<span class="font-medium text-gray-900">{{.Entrypoints}}</span>
|
|
||||||
<span class="text-gray-500">({{.ActiveEntrypoints}} active)</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Targets</span>
|
|
||||||
<span class="font-medium text-gray-900">{{.Targets}}</span>
|
|
||||||
<span class="text-gray-500">({{.ActiveTargets}} active)</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Deliveries in progress</span>
|
|
||||||
<span class="font-medium text-gray-900">{{.InProgress}}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Last event</span>
|
|
||||||
<span class="font-medium text-gray-900">{{with .LastEventAt}}{{.Format "2006-01-02 15:04:05 UTC"}}{{else}}none{{end}}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Retention</span>
|
|
||||||
<span class="font-medium text-gray-900">{{$.Webhook.RetentionLabel}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-4 grid grid-cols-1 lg:grid-cols-2 gap-6 text-sm">
|
|
||||||
<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">Last 10 minutes</th>
|
|
||||||
<th class="py-2 font-medium">Last 24 hours</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="py-2 text-left text-gray-600">Events</td>
|
|
||||||
<td class="py-2">{{.Last10Minutes.Events}}</td>
|
|
||||||
<td class="py-2">{{.Last24Hours.Events}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="py-2 text-left text-gray-600">Failures</td>
|
|
||||||
<td class="py-2">{{.Last10Minutes.Failed}}</td>
|
|
||||||
<td class="py-2">{{.Last24Hours.Failed}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="py-2 text-left text-gray-600">Failure percentage</td>
|
|
||||||
<td class="py-2">{{.Last10Minutes.FailurePercent}}</td>
|
|
||||||
<td class="py-2">{{.Last24Hours.FailurePercent}}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="mt-2 text-xs text-gray-500">Failure percentage is the failed deliveries out of all deliveries that finished in the window. Deliveries still pending or retrying are not counted.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{else}}
|
|
||||||
<div class="p-4 text-sm text-gray-500">The statistics could not be read.</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
Reference in New Issue
Block a user