1 Commits
Author SHA1 Message Date
sneak d9ededcb08 Rewrite README.md sentence by sentence
check / check (push) Canceled after 0s
Rewritten from scratch against the code on next. The old text was
the list of what a reader needs covered.

Model: fable-5-1
2026-10-25 04:16:55 +00:00
+349 -235
View File
@@ -1,20 +1,32 @@
# webhooker # webhooker
webhooker receives webhooks, stores them, and forwards them to the places you webhooker is an MIT-licensed web service, written in [Go](https://golang.org) by
choose. A webhook is an HTTP request that one service sends to another when [@sneak](https://sneak.berlin), that receives the HTTP notifications other
something happens. Senders usually make one attempt and keep no record. If your services send and forwards them to your own systems.
receiver is down at that moment, the event is lost.
webhooker sits between the sender and your receiver. It is a single Go binary Many services send an HTTP request to a URL you give them when something
with a web interface and SQLite storage. It accepts every event the sender posts happens: a code push, a payment, a new ticket. webhooker receives those
and writes it to disk before answering. It then delivers the event to each requests, stores them, and forwards them to the places you choose.
target you configured, with retries. The web interface shows each webhook's
newest events, with every delivery and attempt they had. From there you can send
a stored event again. It also exports metrics for Prometheus, the monitoring
system, so you can watch the whole flow.
webhooker is written in [Go](https://golang.org) by webhooker is a single binary with a web interface. It stores its data in SQLite,
[@sneak](https://sneak.berlin). The license is MIT. a database engine that keeps each database in one ordinary file. It writes each
notification to disk before answering the sender. It then delivers it to each
target you configured. The web interface shows the newest notifications, with
every delivery and attempt they had. From there you can send a stored
notification again. webhooker also exports metrics for Prometheus, the
monitoring system, so you can watch the whole flow.
## Rationale
Senders of these notifications usually make one attempt and keep no record. If
your service is down at that moment, the notification is lost. Few senders let
you see what they sent, or when, or whether it arrived.
webhooker sits between the sender and your service and fixes those three things.
It is always ready to receive, and it stores before it forwards, so nothing is
lost while your service is down. It keeps every notification and every delivery
attempt, so you can read what was sent and what happened to it. It can forward
one notification to several targets, and it can send a stored one again.
## How it works ## How it works
@@ -26,9 +38,9 @@ Five words name the moving parts.
it. The UUID is a random 36-character identifier, and it is the only thing it. The UUID is a random 36-character identifier, and it is the only thing
that authorises a sender. that authorises a sender.
- A **target** is a place events are delivered to. There are four kinds: an HTTP - A **target** is a place events are delivered to. There are four kinds: an HTTP
URL, a Slack or Mattermost incoming webhook, an archive database file, and the URL, a Slack or Mattermost message-posting URL, an archive database file, and
application log. the application log.
- An **event** is one stored request: its method, query string, headers and - An **event** is one stored notification: its method, query string, headers and
body. body.
- A **delivery** is one event paired with one target. It records every attempt. - A **delivery** is one event paired with one target. It records every attempt.
@@ -42,9 +54,9 @@ Delivery is at least once, not exactly once. If a delivery reaches the target
but webhooker cannot record that outcome, it sends the delivery again later. A but webhooker cannot record that outcome, it sends the delivery again later. A
delivery in flight when the service stops is also sent again at the next start. delivery in flight when the service stops is also sent again at the next start.
webhooker adds no delivery identifier of its own to the request, so make your webhooker adds no delivery identifier of its own to the request, so make your
receiver tolerate a duplicate. targets tolerate a duplicate.
## Quick start ## Getting Started
You need the Go version named in `go.mod`, and Docker for the checks. You need the Go version named in `go.mod`, and Docker for the checks.
@@ -113,7 +125,7 @@ target named `HTTP`. The **Archive** checkbox creates an archive target named
Every new webhook gets one entrypoint, described as `Default entrypoint`. Every new webhook gets one entrypoint, described as `Default entrypoint`.
### Entrypoints ### Entrypoint URLs
The webhook page lists each entrypoint with its full URL and a **Copy** button. The webhook page lists each entrypoint with its full URL and a **Copy** button.
Give that URL to the sender. Anyone who holds it can post events, so treat it Give that URL to the sender. Anyone who holds it can post events, so treat it
@@ -138,10 +150,11 @@ events arrived on it within the retention period.
- An `http` target posts each event to a URL. It takes optional request headers, - An `http` target posts each event to a URL. It takes optional request headers,
a timeout in seconds, and a switch to pass the event's query string on. a timeout in seconds, and a switch to pass the event's query string on.
- A `slack` target posts each event as a message to a Slack or Mattermost - A `slack` target posts each event as a message to Slack or Mattermost. It
incoming webhook URL. takes the message-posting URL those services give out, which they call an
incoming webhook.
- An archive target writes each event as a row in a SQLite file of its own. Its - An archive target writes each event as a row in a SQLite file of its own. Its
type is `database` in the data. It takes an expiry and a rotation. stored type is `database`. It takes an expiry and a rotation.
- A `log` target writes each event to the application log. It has no settings - A `log` target writes each event to the application log. It has no settings
beyond its name. beyond its name.
@@ -170,9 +183,9 @@ The top of the webhook page shows counts of entrypoints and targets, deliveries
in progress, the time of the last event, and the retention setting. in progress, the time of the last event, and the retention setting.
Below that are events, deliveries and failures, both lifetime and within Below that are events, deliveries and failures, both lifetime and within
retention. Then come the last 10 minutes and the last 24 hours: events, retention. Next are figures for the last 10 minutes and the last 24 hours:
failures, and the failure percentage. The percentage is failed deliveries out of events, failures and the failure percentage. The percentage is failed deliveries
all deliveries that finished in the window. out of all deliveries that finished in the window.
The webhook list shows three of these figures per webhook: events within The webhook list shows three of these figures per webhook: events within
retention, the last event, and failed deliveries in the last 24 hours. retention, the last event, and failed deliveries in the last 24 hours.
@@ -240,7 +253,7 @@ docker run -d \
webhooker webhooker
``` ```
The image listens on every address inside the container: it ships The image listens on every IPv4 address inside the container: it ships
`BIND_ADDRESS=0.0.0.0`. A container has its own network namespace, which means `BIND_ADDRESS=0.0.0.0`. A container has its own network namespace, which means
its own addresses, separate from the host's. That is the boundary, and the `-p` its own addresses, separate from the host's. That is the boundary, and the `-p`
flag decides what the host exposes. Publish to `127.0.0.1:8080`, the address flag decides what the host exposes. Publish to `127.0.0.1:8080`, the address
@@ -279,8 +292,8 @@ docker run --rm -v webhooker-data:/var/lib/webhooker \
### Running under upaas ### Running under upaas
[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this repository's [upaas](https://git.eeqj.de/sneak/upaas), a deployment tool, builds the image
`Dockerfile` and runs it. from this repository's `Dockerfile` and runs it.
- Add no port mapping. upaas publishes every mapped port on all interfaces of - Add no port mapping. upaas publishes every mapped port on all interfaces of
the host. Instead, set the app's Docker network to your reverse proxy's the host. Instead, set the app's Docker network to your reverse proxy's
@@ -312,13 +325,15 @@ docker start upaas-webhooker
### Behind a reverse proxy ### Behind a reverse proxy
webhooker speaks plain HTTP and terminates no TLS. Put a reverse proxy in front webhooker speaks plain HTTP. It does not terminate TLS, the encryption behind
of it. The proxy holds the certificate and reaches webhooker over loopback. HTTPS. Put a reverse proxy in front of it: a server that accepts the public
connections and passes them on. The proxy holds the certificate and reaches
webhooker over loopback, the address only this host can reach.
Five things must be right. Five things must be right.
1. **Keep the app port private.** The binary binds `127.0.0.1` by default. The 1. **Keep the app port private.** The binary binds `127.0.0.1` by default. The
image binds every address inside its container, so publish its port to image binds every IPv4 address inside its container, so publish its port to
`127.0.0.1` only. The plain port serves the sign-in form and the receiver `127.0.0.1` only. The plain port serves the sign-in form and the receiver
without TLS. without TLS.
2. **Send `X-Forwarded-Proto: https`.** This header tells webhooker the request 2. **Send `X-Forwarded-Proto: https`.** This header tells webhooker the request
@@ -327,9 +342,9 @@ Five things must be right.
webhooker reads this header from any peer, so the proxy must overwrite webhooker reads this header from any peer, so the proxy must overwrite
whatever the client sent. whatever the client sent.
3. **Cover the proxy in `TRUSTED_PROXIES`.** Otherwise every client shares one 3. **Cover the proxy in `TRUSTED_PROXIES`.** Otherwise every client shares one
rate-limit bucket, the count kept per client, keyed on the proxy's address. rate-limit bucket. A bucket is the count a limit keeps per client, and here
The default list is the private ranges, which does not cover a proxy that it would be keyed on the proxy's address. The default list is the private
connects over loopback. ranges, which does not cover a proxy that connects over loopback.
4. **Send `Host` as `$http_host`, not `$host`.** `$host` drops the port. The 4. **Send `Host` as `$http_host`, not `$host`.** `$host` drops the port. The
form origin check compares against the host it was given. On any port other form origin check compares against the host it was given. On any port other
than 443 every form submission then fails with `403`. than 443 every form submission then fails with `403`.
@@ -376,7 +391,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# Above webhooker's own 60 second request timeout, so its # Above webhooker's own 60 second request timeout, so its
# 503 reaches the client instead of nginx's 504. # 504 reaches the client before nginx gives up.
proxy_read_timeout 70s; proxy_read_timeout 70s;
} }
} }
@@ -465,8 +480,9 @@ All state lives in `DATA_DIR`. Back up the whole directory. It holds:
Every database runs in WAL mode. WAL is SQLite's write-ahead log: committed Every database runs in WAL mode. WAL is SQLite's write-ahead log: committed
changes land in a `-wal` file beside the database before they reach the `.db` changes land in a `-wal` file beside the database before they reach the `.db`
itself. So a running service has `{name}.db-wal` and `{name}.db-shm` beside each itself. So a running service has two more files beside each `{name}.db`:
`{name}.db`. The `-wal` file is part of the database. A copy of the `.db` `{name}.db-wal`, the log itself, and `{name}.db-shm`, the shared index SQLite
keeps for that log. The `-wal` file is part of the database. A copy of the `.db`
without it is missing data and may have no readable tables at all. without it is missing data and may have no readable tables at all.
A clean shutdown closes every database and removes its `-wal` and `-shm` files. A clean shutdown closes every database and removes its `-wal` and `-shm` files.
@@ -554,21 +570,31 @@ What that means in practice:
access log does hold it, in the request path. access log does hold it, in the request path.
- To rotate, create a new entrypoint and point the sender at it. Then deactivate - To rotate, create a new entrypoint and point the sender at it. Then deactivate
or delete the old one. or delete the old one.
- A sender that can only sign payloads to a fixed URL is a limit of that sender. - Some senders cannot be given a secret URL: they post to one fixed address and
Pick another integration path for it. prove themselves with a signature instead. webhooker checks no signatures, so
such a sender cannot use it. Pick another integration path for it.
### The web interface ### The web interface
- Passwords are hashed with Argon2id, a memory-hard password hashing function, - Passwords are hashed with Argon2id, a password hashing function designed to be
using 64 MB of memory, 1 pass, 4 threads, a 32-byte key and a 16-byte salt. slow and to need a large amount of memory. The parameters are:
- Session cookies are signed, `HttpOnly` and `SameSite=Lax`. They carry `Secure` - 64 MB of memory;
when the request arrived over TLS, judged per request from a direct TLS - 1 pass;
connection or `X-Forwarded-Proto: https`. - 4 threads;
- The session is regenerated at sign-in, which prevents session fixation. - a 32-byte key;
- a 16-byte salt.
- Session cookies are signed, `HttpOnly` (hidden from page scripts) and
`SameSite=Lax` (not sent with cross-site form posts). They carry `Secure` when
the request arrived over TLS, judged per request from a direct TLS connection
or `X-Forwarded-Proto: https`.
- A new session is issued at sign-in. This prevents session fixation, an attack
where a victim is made to use a session the attacker already knows.
- The key that signs the cookies is 32 random bytes made on first start and - The key that signs the cookies is 32 random bytes made on first start and
stored in `webhooker.db`. stored in `webhooker.db`.
- Every form is protected against cross-site request forgery with a token tied - Every form is protected against cross-site request forgery (CSRF), where
to a cookie. Over TLS the form's `Origin` or `Referer` must also match the another site makes your browser submit a form here. Each form carries a token
tied to a cookie. A submission with an `Origin` header must name this host.
Over TLS, a submission without `Origin` must carry a `Referer` that names this
host. host.
- Form bodies are capped at 1 MB. A larger declared body is answered `413` - Form bodies are capped at 1 MB. A larger declared body is answered `413`
before anything reads it. before anything reads it.
@@ -576,7 +602,7 @@ What that means in practice:
- `Strict-Transport-Security`, unless `HSTS_MAX_AGE` is `0`; - `Strict-Transport-Security`, unless `HSTS_MAX_AGE` is `0`;
- `X-Content-Type-Options: nosniff`; - `X-Content-Type-Options: nosniff`;
- `X-Frame-Options: DENY`; - `X-Frame-Options: DENY`;
- `Content-Security-Policy`, which tells the browser where scripts and - `Content-Security-Policy` (CSP), which tells the browser where scripts and
styles may come from, set to styles may come from, set to
`default-src 'self'; script-src 'self'; style-src 'self'`; `default-src 'self'; script-src 'self'; style-src 'self'`;
- `Referrer-Policy: strict-origin-when-cross-origin`; - `Referrer-Policy: strict-origin-when-cross-origin`;
@@ -622,7 +648,8 @@ the proxy.
A delivery target could be used to make webhooker send requests into the network A delivery target could be used to make webhooker send requests into the network
it sits in and read the answers back. This is called server-side request it sits in and read the answers back. This is called server-side request
forgery. webhooker refuses it by default. forgery. webhooker refuses it by default, with a check called the destination
check below.
Every target URL must use `http` or `https` and resolve to a public address. Every target URL must use `http` or `https` and resolve to a public address.
These private and reserved ranges are refused: These private and reserved ranges are refused:
@@ -635,20 +662,22 @@ These private and reserved ranges are refused:
- link-local space, `169.254.0.0/16` and `fe80::/10`, which is valid only on one - link-local space, `169.254.0.0/16` and `fe80::/10`, which is valid only on one
network segment; network segment;
- the IPv6 unique local range, `fc00::/7`; - the IPv6 unique local range, `fc00::/7`;
- multicast, documentation space and the other reserved ranges. - multicast, the ranges reserved for documentation, and the other reserved
ranges.
One public address is refused too: `168.63.129.16`, the Azure service that hands One public address is refused too: `168.63.129.16`, the Azure service that hands
a VM its credentials. a virtual machine its credentials.
The check runs when a target is saved and again when each delivery connects. The destination check runs when a target is saved and again when each delivery
Both use the same function. The address is resolved again at connect time, so a connects. Both use the same function. The address is resolved again at connect
hostname that changes its address after validation is still checked. time, so a hostname that changes its address after validation is still checked.
Redirects are followed up to ten hops. Each hop is checked the same way. A hop Redirects are followed up to ten hops. Each hop is checked the same way. A hop
that leaves the target's origin carries none of the target's configured headers that leaves the target's origin carries none of the target's configured headers
and none of the forwarded event headers. Leaving the origin means a different and none of the forwarded event headers. Leaving the origin means a different
host, a different port, or a step from `https` to `http`. This stops an open host, a different port, or a step from `https` to `http`. This stops an open
redirect from handing a credential to another host. redirect, a destination that forwards requests to any URL a caller names, from
handing a credential to another host.
`ALLOWED_EGRESS_CIDRS` opens named networks for the common case of forwarding to `ALLOWED_EGRESS_CIDRS` opens named networks for the common case of forwarding to
your own network. Read your own network. Read
@@ -657,18 +686,19 @@ before setting it.
### What the logs contain ### What the logs contain
webhooker writes structured logs to standard output: plain text on a terminal, webhooker writes structured logs, one record per line with named fields, to
JSON otherwise. Each request writes one access log line, `http request`, at standard output: plain text on a terminal, JSON otherwise. Each request writes
`INFO`. A request to a known entrypoint writes a second line, one access log line, `http request`, at `INFO`. A request to a known entrypoint
`webhook request received`. A stored event writes a third, writes a second line, `webhook request received`. A stored event writes a third,
`webhook event created`. `webhook event created`.
Fields a client can influence are cut to a fixed size, so one access log line is Fields a client can influence are cut to a fixed size, so one access log line is
at most 2,560 bytes. The query string is never logged. On the receiver route the at most 2,560 bytes. The access log never records the query string. On the
URL is logged as `/h/{uuid}`, never the real path, so entrypoint UUIDs stay out receiver route the URL is logged as `/h/{uuid}`, never the real path, so
of the log. On other routes a `3xx` or `4xx` response logs the route pattern, entrypoint UUIDs stay out of the log. On other routes a `3xx` or `4xx` response
which is the route as registered, such as `/user/{username}`. A `2xx` or `5xx` logs the route pattern, which is the route as registered, such as
logs the real path. The `Referer` is logged as its scheme and host only. `/user/{username}`. A `2xx` or `5xx` logs the real path. The `Referer` is logged
as its scheme and host only.
Every line that names a client carries two fields. `remoteIP` is the connecting Every line that names a client carries two fields. `remoteIP` is the connecting
peer, which behind a proxy is the proxy. `clientIP` is the client as decided by peer, which behind a proxy is the proxy. `clientIP` is the client as decided by
@@ -682,11 +712,13 @@ the first boot, and again when `webhooker resetpw -generate` is run. The second
is the operator's own configuration echoed back: webhook names and target is the operator's own configuration echoed back: webhook names and target
hostnames. hostnames.
A `log` target writes the whole event, headers and body, to the log. That is its A `log` target writes the whole event to the log: query string, headers and
purpose. body. That is its purpose.
A recovered panic writes one `ERROR` record of at most 10,240 bytes, with the A recovered panic is a crash inside one request that the server caught. It
request id of the request that caused it. writes one `ERROR` record of at most 10,240 bytes. The record carries the
request id of the request that caused it. A request id is a random token given
to each request and written in its log lines.
When `SENTRY_DSN` is set, error reports go to Sentry, an error reporting When `SENTRY_DSN` is set, error reports go to Sentry, an error reporting
service, with the request reduced to this: service, with the request reduced to this:
@@ -697,6 +729,114 @@ service, with the request reduced to this:
- only these headers: `Accept`, `Content-Length`, `Content-Type`, `Host`, - only these headers: `Accept`, `Content-Length`, `Content-Type`, `Host`,
`Origin`, `Referer` (scheme and host only), `User-Agent` and `X-Request-Id`. `Origin`, `Referer` (scheme and host only), `User-Agent` and `X-Request-Id`.
## Design
### Request flow
One request to an entrypoint goes through these steps:
1. The router matches `POST /h/{uuid}`. Any other method gets `405`.
2. The rate limits run: one per client per entrypoint, one per client across the
whole route.
3. The handler looks up the entrypoint. Unknown gets `404`, deactivated `410`.
4. The handler reads the body, up to 1 MB. A larger body gets `413`.
5. One transaction stores the event, one pending delivery per active target, and
the running totals. The sender gets `200`.
6. One task per delivery goes onto the delivery queue.
7. A worker takes the task and runs the target's delivery. The outcome is
recorded as an attempt, and the delivery's status is updated.
### Components and middleware
The components are wired by Uber fx, a library that builds each component when
another needs it. It runs their start hooks (the code each component runs at
start) in that order, and their stop hooks in reverse. The components are:
- the globals, the logger and the config;
- the main database and the per-webhook database manager;
- the retention sweep and the health check;
- the session and the handlers;
- the metrics registry and collectors;
- the middleware and the destination check;
- the delivery engine, the archive writers and the archive sweep;
- the server.
Middleware is code that runs around every request. The global middleware runs in
this order:
1. request id;
2. security headers;
3. access log;
4. metrics, when `/metrics` is served;
5. CORS;
6. a 60 second request timeout, which answers `504` when it fires;
7. panic recovery;
8. Sentry, when configured.
A page route group is a set of routes under one prefix, such as `/hook/`. Each
page route group then adds its own middleware, in this order:
1. panic recovery;
2. Sentry, when configured;
3. the 1 MB body cap;
4. CSRF;
5. `Cache-Control: no-store`;
6. the sign-in check, in every group except `/pages`, which serves the sign-in
form.
The body cap comes before CSRF because CSRF parses the form.
### Libraries
- [Uber fx](https://go.uber.org/fx) wires the components, as above.
- [chi](https://github.com/go-chi/chi), an HTTP router, matches requests to
handlers.
- [GORM](https://gorm.io), a Go database library, with
[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite), a pure Go SQLite
driver, handles the databases.
- `log/slog`, the standard library's structured logger, writes the logs.
- [gorilla/sessions](https://github.com/gorilla/sessions) and
[gorilla/csrf](https://github.com/gorilla/csrf) handle sessions and the CSRF
protection.
- [go-chi/httprate](https://github.com/go-chi/httprate) rate-limits the
receiver, the password change, replay and resubmit.
- The [Prometheus](https://prometheus.io) client and
[go-http-metrics](https://github.com/slok/go-http-metrics) serve the metrics.
- The [Sentry](https://sentry.io) client reports errors when configured.
### Package layout
All application code is under `internal/`. The program starts in
`cmd/webhooker/main.go`, which dispatches subcommands, takes the `DATA_DIR` lock
and wires the components.
- `internal/archive`: archive files, rotation, pruning, renaming and download.
- `internal/banner`: the ruled box that prints a credential.
- `internal/config`: configuration from the environment and `.env`.
- `internal/database`: the main database, the models, Argon2id, the retention
sweep and the per-webhook database manager.
- `internal/datadir`: the exclusive lock on `DATA_DIR`.
- `internal/db/migrations`: the SQL schema files and their application.
- `internal/delivery`: the engine, the four target types, retries, the circuit
breaker and the destination check.
- `internal/globals`: build-time variables.
- `internal/gormlog`: GORM's logger on top of `slog`, with values withheld.
- `internal/handlers`: every HTTP handler and page.
- `internal/healthcheck`: the health check response.
- `internal/lifecycle`: the bounded wait used by stop hooks.
- `internal/logfield`: the size budget for client-supplied log values.
- `internal/logger`: `slog` setup and the fx event logger.
- `internal/metrics`: the delivery metrics.
- `internal/middleware`: logging, security headers, CORS, sessions, CSRF, rate
limits, body caps, metrics and panic recovery.
- `internal/referer`: cuts a `Referer` to its scheme and host.
- `internal/reqtls`: the one function that decides whether a request is TLS.
- `internal/resetpw`: the `webhooker resetpw` subcommand.
- `internal/server`: the HTTP server, routes, middleware order and Sentry.
- `internal/session`: the cookie session.
- `internal/versionscript`: tests for `script/version` and the build files.
- `static/`: embedded CSS and JavaScript. `templates/`: the HTML templates.
## Configuration reference ## Configuration reference
All configuration comes from environment variables. For development, variables All configuration comes from environment variables. For development, variables
@@ -714,8 +854,8 @@ A Go duration is a number with a unit, such as `30s`, `15m`, `24h` or `1h30m`.
The startup log line `Configuration loaded` shows the loaded values, with three The startup log line `Configuration loaded` shows the loaded values, with three
differences. `TRUSTED_PROXIES` and `ALLOWED_EGRESS_CIDRS` appear as counts. differences. `TRUSTED_PROXIES` and `ALLOWED_EGRESS_CIDRS` appear as counts.
`SENTRY_DSN` and the metrics credentials appear as on or off. `METRICS_USERNAME` `SENTRY_DSN` appears as on or off. The metrics credentials appear as one
does not appear. The `/settings` page shows every value; there, on-or-off field. The `/settings` page shows every value; there,
`METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set. `METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set.
### `WEBHOOKER_ENVIRONMENT` ### `WEBHOOKER_ENVIRONMENT`
@@ -799,9 +939,10 @@ startup fails with an error naming both variables.
### `SENTRY_DSN` ### `SENTRY_DSN`
A Sentry DSN for error reporting. Default empty, which leaves reporting off. A The DSN for Sentry error reporting: the URL that tells the Sentry client where
set value is parsed with the Sentry SDK's own parser, and one it cannot parse to send reports. Default empty, which leaves reporting off. A set value is
aborts startup. The release name sent to Sentry is `webhooker-{version}`. parsed with the Sentry client's own parser, and one it cannot parse aborts
startup. The release name sent to Sentry is `webhooker-{version}`.
### `RETENTION_SWEEP_INTERVAL` ### `RETENTION_SWEEP_INTERVAL`
@@ -848,10 +989,11 @@ address and a prefix length, such as `10.0.0.0/8`. A bare address such as
`10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`, the RFC 1918 private ranges. A set `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`, the RFC 1918 private ranges. A set
value replaces the default. value replaces the default.
The list decides whose `X-Forwarded-For` header is believed. When the connecting The list decides whose `X-Forwarded-For` header is believed. `X-Forwarded-For`
peer is inside the list, the client address is read from that header. For any is the header a proxy uses to pass on the address of the client it is serving.
other peer, the client is the peer itself and the header is ignored. `X-Real-IP` When the connecting peer is inside the list, the client address is read from
and `True-Client-IP` are never read. that header. For any other peer, the client is the peer itself and the header is
ignored. `X-Real-IP` and `True-Client-IP` are never read.
The header is read from right to left. The first hop that is not itself a The header is read from right to left. The first hop that is not itself a
trusted proxy is the client. A hop that is not a bare IP address ends the walk, trusted proxy is the client. A hop that is not a bare IP address ends the walk,
@@ -889,8 +1031,9 @@ blocks that cover your destinations. Prefer `10.1.2.3/32` over `10.0.0.0/8`.
this host's own loopback. Do not list them. this host's own loopback. Do not list them.
Some addresses stay blocked whatever you list. They are link-local space, the Some addresses stay blocked whatever you list. They are link-local space, the
unspecified addresses, and cloud metadata endpoints at fixed non-public unspecified addresses `0.0.0.0` and `::`, and cloud metadata endpoints at fixed
addresses that hand out credentials: non-public addresses. A cloud metadata endpoint is the address a cloud gives
each virtual machine for its own credentials and settings. The blocked set is:
- `169.254.0.0/16` and `fe80::/10`, the link-local ranges. `169.254.169.254` is - `169.254.0.0/16` and `fe80::/10`, the link-local ranges. `169.254.169.254` is
the metadata address on AWS, Azure, DigitalOcean, Hetzner, OpenStack and the metadata address on AWS, Azure, DigitalOcean, Hetzner, OpenStack and
@@ -939,10 +1082,14 @@ These event headers are never forwarded:
An `http` target's settings: An `http` target's settings:
- `url`, the destination. Only `http` and `https` are accepted. - `url`, the destination. Only `http` and `https` are accepted.
- `headers`, one `Name: value` per line. `Host`, `Content-Length`, - `headers`, one `Name: value` per line. `Content-Type` is allowed and replaces
`Transfer-Encoding`, `Connection`, `Trailer` and `User-Agent` are refused, the event's. These names are refused, because the delivery sets them itself:
because the delivery sets them itself. `Content-Type` is allowed and replaces - `Host`
the event's. - `Content-Length`
- `Transfer-Encoding`
- `Connection`
- `Trailer`
- `User-Agent`
- `timeout_seconds`, the per-request timeout, at most 300. Blank means the - `timeout_seconds`, the per-request timeout, at most 300. Blank means the
shared 30 second default. A long timeout holds one of the ten delivery workers shared 30 second default. A long timeout holds one of the ten delivery workers
for its whole duration. for its whole duration.
@@ -952,8 +1099,8 @@ An `http` target's settings:
A `slack` target posts a JSON message with one `text` field to its URL. The A `slack` target posts a JSON message with one `text` field to its URL. The
message names the method, content type, time received and body size, then shows message names the method, content type, time received and body size, then shows
the body in a code block. A JSON body is indented. A body over 3500 bytes is the body in a code block. A JSON body is indented. A body over 3500 bytes is
cut. The URL is any Slack-compatible incoming webhook URL, which includes cut. The URL is any message-posting URL that accepts Slack's format, which
Mattermost. includes Mattermost's.
A `database` target, called an archive in the web interface, appends the event A `database` target, called an archive in the web interface, appends the event
as a row to its own SQLite file. See [Archives](#archives). It makes no network as a row to its own SQLite file. See [Archives](#archives). It makes no network
@@ -985,10 +1132,11 @@ circuit breaker stops hammering a destination that is down. It is in memory only
and resets when the service restarts. and resets when the service restarts.
The breaker is closed in normal operation. After 5 failures in a row it opens The breaker is closed in normal operation. After 5 failures in a row it opens
for 30 seconds. While it is open, each delivery to the target is marked for 30 seconds. That wait is the cooldown. While the breaker is open, each
`retrying` and scheduled for when the cooldown ends. When the cooldown ends, one delivery to the target is marked `retrying` and scheduled for when the cooldown
delivery is sent as a probe. The others wait one more cooldown. If the probe ends. When the cooldown ends, one delivery is sent as a probe. The others wait
succeeds, the breaker closes. If it fails, the breaker opens again. one more cooldown. If the probe succeeds, the breaker closes. If it fails, the
breaker opens again.
The webhook page says when a target's deliveries are paused and until when. Each The webhook page says when a target's deliveries are paused and until when. Each
waiting delivery in the event log shows the earliest time it can be tried. waiting delivery in the event log shows the earliest time it can be tried.
@@ -1025,10 +1173,10 @@ the delivery `failed` and records the reason.
### Shutdown ### Shutdown
On `SIGINT` or `SIGTERM`, components stop in reverse start order under a 5 On `SIGINT` or `SIGTERM`, components stop in reverse start order under a 5
second budget for the whole sequence. The HTTP server drains open requests for second budget for the whole sequence. The HTTP server drains open requests,
at most 3 seconds, leaving 2 seconds for the components after it. A Sentry which means it waits for them to finish, for at most 3 seconds. That leaves 2
flush, if enabled, fits inside the same budget and is skipped when little is seconds for the components after it. A Sentry flush, if enabled, fits inside the
left. same budget and is skipped when little is left.
The delivery engine cancels any `http` or `slack` request in flight. That The delivery engine cancels any `http` or `slack` request in flight. That
attempt is not recorded, and the delivery keeps its status, so it is sent again attempt is not recorded, and the delivery keeps its status, so it is sent again
@@ -1038,8 +1186,9 @@ A component that does not stop in time is logged at `ERROR`, and the components
after it are skipped. SQLite is crash-safe, so this loses no data, but the after it are skipped. SQLite is crash-safe, so this loses no data, but the
database is not closed cleanly. database is not closed cleanly.
Keep the container's stop grace period above 5 seconds. Docker's default is 10. Keep the container's stop grace period above 5 seconds. That is the time Docker
A shorter grace kills the process before the shutdown can run. waits after `docker stop` before it kills the process. Docker's default is 10. A
shorter grace kills the process before the shutdown can run.
### Archives ### Archives
@@ -1099,9 +1248,9 @@ entrypoints and targets. Each `events-{webhook_uuid}.db` holds that webhook's
event tier: events, deliveries, delivery attempts and running totals. Each event tier: events, deliveries, delivery attempts and running totals. Each
`archive-*.db` holds one archive target's rows. `archive-*.db` holds one archive target's rows.
Every database is opened through one code path, in WAL mode, with a 10 second Every database is opened through one code path, in WAL mode, with at most 4
busy timeout and at most 4 connections. Under WAL a reader never blocks a connections. A connection waits up to 10 seconds for a locked database before
writer. giving up. Under WAL a reader never blocks a writer.
An event database is created with its webhook. Every webhook's database is An event database is created with its webhook. Every webhook's database is
opened at start. One that is missing or empty is created again and logged with opened at start. One that is missing or empty is created again and logged with
@@ -1111,8 +1260,9 @@ that line means the file was lost, most often because the volume holding
`ERROR` naming it and a `500` to its senders. `ERROR` naming it and a `500` to its senders.
Deleting a webhook soft-deletes its row, entrypoints and targets in Deleting a webhook soft-deletes its row, entrypoints and targets in
`webhooker.db` and deletes its event database file. Deleting an entrypoint or a `webhooker.db` and deletes its event database file. A soft delete marks the row
target soft-deletes it. with a deletion time and hides it from the application; the row stays in the
file. Deleting an entrypoint or a target soft-deletes it.
### Schema ### Schema
@@ -1126,8 +1276,7 @@ transaction.
### Application tier ### Application tier
Every entity below except `Setting` has `id` (a version 4 UUID), `created_at`, Every entity below except `Setting` has `id` (a version 4 UUID), `created_at`,
`updated_at` and `deleted_at`. A row with `deleted_at` set is soft-deleted and `updated_at` and `deleted_at`. A row with `deleted_at` set is soft-deleted.
hidden from the application.
`Setting` is a key-value row with no other fields. The one key stored is `Setting` is a key-value row with no other fields. The one key stored is
`session_key`, the base64 session key made on first start. `session_key`, the base64 session key made on first start.
@@ -1168,18 +1317,25 @@ bare UUID. The `/h/` prefix is route only and is not stored.
`Delivery` has `event_id`, `target_id`, `status`, `finished_at` and `replay`. `Delivery` has `event_id`, `target_id`, `status`, `finished_at` and `replay`.
`finished_at` is set when the status becomes `delivered` or `failed`. `finished_at` is set when the status becomes `delivered` or `failed`.
`DeliveryResult` is one attempt. It has `delivery_id`, `attempt_num`, `success`, `DeliveryResult` is one attempt. It has these fields:
`status_code`, `response_body` (the first 4 KiB), `error` and `duration` in
milliseconds.
Three tables hold running totals so the pages need not count rows. `EventTotals` - `delivery_id` and `attempt_num`;
is one row: `events`, `events_removed` and `last_event_at`. `TargetTotals` is - `success` and `status_code`;
one row per target: `deliveries`, `delivered`, `failed`, `deliveries_removed` - `response_body`, the first 4 KiB;
and `failed_removed`. `EntrypointTotals` is one row per entrypoint: - `error`;
`last_event_at`. Each count changes in the transaction that writes or deletes - `duration`, in milliseconds.
the rows it counts. Retention raises the `_removed` counts and leaves
`last_event_at` alone, so a webhook's last event is still shown after its events Three tables hold running totals so the pages need not count rows. Each count
have expired. changes in the transaction that writes or deletes the rows it counts.
- `EventTotals` is one row: `events`, `events_removed` and `last_event_at`.
- `TargetTotals` is one row per target, with:
- `deliveries`, `delivered` and `failed`;
- `deliveries_removed` and `failed_removed`.
- `EntrypointTotals` is one row per entrypoint: `last_event_at`.
Retention raises the `_removed` counts and leaves `last_event_at` alone, so a
webhook's last event is still shown after its events have expired.
## HTTP routes reference ## HTTP routes reference
@@ -1237,8 +1393,10 @@ set, behind basic authentication.
## Metrics reference ## Metrics reference
`/metrics` serves one Prometheus registry. It carries the Go runtime and process `/metrics` serves one Prometheus registry, the set of metrics it exposes. It
collectors, the delivery metrics below, and three inbound HTTP metrics. carries the Go runtime and process collectors, which report the process's
memory, threads and CPU use, the delivery metrics below, and three inbound HTTP
metrics.
Nine of the eleven metrics below carry one label, `target_type`, with one of Nine of the eleven metrics below carry one label, `target_type`, with one of
`http`, `slack`, `database`, `log` or `unknown`. The two event counters carry `http`, `slack`, `database`, `log` or `unknown`. The two event counters carry
@@ -1271,7 +1429,8 @@ The inbound HTTP metrics are `http_request_duration_seconds`,
`http_response_size_bytes` and `http_requests_inflight`. Their `handler` label `http_response_size_bytes` and `http_requests_inflight`. Their `handler` label
is the route pattern, such as `/h/{uuid}`, never the real path. A request that is the route pattern, such as `/h/{uuid}`, never the real path. A request that
matched no route carries `(unmatched)`. `http_requests_inflight` is one series matched no route carries `(unmatched)`. `http_requests_inflight` is one series
for the whole service, with `handler` set to `(all)`. (one metric with one set of label values) for the whole service, with `handler`
set to `(all)`.
## Development ## Development
@@ -1282,13 +1441,14 @@ for the whole service, with `handler` set to `(all)`.
Docker. Docker.
`gofmt` and `goimports` run on the host. These tools run only inside Docker, at `gofmt` and `goimports` run on the host. These tools run only inside Docker, at
the versions pinned in the `Dockerfile`, `package.json` and `yarn.lock`: the versions pinned (fixed to one exact release) in the `Dockerfile`,
`package.json` and `yarn.lock`:
- `golangci-lint`; - `golangci-lint`, the Go linter;
- ESLint; - ESLint, the JavaScript linter;
- prettier; - prettier, the Markdown formatter;
- node and yarn; - node and yarn, which run the two above;
- the Tailwind CLI. - the Tailwind CLI, which generates the stylesheet.
### Commands ### Commands
@@ -1314,36 +1474,35 @@ make css-check # Fail if static/css/tailwind.css is out of date
make clean # Remove bin/ make clean # Remove bin/
``` ```
The repository follows
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all).
Each `make` target above, except `build`, `run`, `dev`, `deps`, `version` and Each `make` target above, except `build`, `run`, `dev`, `deps`, `version` and
`clean`, is a thin shim over a script in `script/`. `make hooks` calls `clean`, is a thin shim over a script in `script/`. `make hooks` calls
`script/install-precommit`; every other shim calls the script of the same name. `script/install-precommit`; every other shim calls the script of the same name.
`script/cibuild` is what CI runs: `script/bootstrap`, then `script/check`, then The scripts are listed under [Entrypoints](#entrypoints).
the image build. `script/precommit` checks that `go mod tidy` changes nothing,
then runs `script/check`.
The tests run with cgo, Go's bridge to C code, turned off. Go's race detector The tests run with cgo, Go's bridge to C code, turned off. Go's race detector,
needs cgo, so the tests run without it. which finds unsynchronised access to shared memory, needs cgo, so the tests run
without it.
### How the checks run ### How the checks run
Each check is a stage of the `Dockerfile`. `script/test` builds the `test` Each check is a stage of the `Dockerfile`, which is one named step of the image
stage, `script/lint` the `lint` stage and `script/css-check` the `css-check` build. `script/test` builds the `test` stage, `script/lint` the `lint` stage and
stage. `script/fmt-check` runs `gofmt` on the host and builds the `script/css-check` the `css-check` stage. `script/fmt-check` runs `gofmt` on the
`markdown-check` stage. Every `docker build` in `script/` passes `--no-cache`, host and builds the `markdown-check` stage. Every `docker build` in `script/`
so a check that reports success ran. A bare `docker build .` carries no such passes `--no-cache`, so Docker reruns every step instead of reusing an earlier
guarantee. result, and a check that reports success ran. A bare `docker build .` carries no
such guarantee.
The `lint` stage runs the `gofmt` check, `golangci-lint config verify` and The `lint` stage runs the `gofmt` check, `golangci-lint config verify` and
`golangci-lint run` with `--network=none`, and ESLint over `static/js/`. The `golangci-lint run` with `--network=none`, and ESLint over `static/js/`. The
`test` stage runs `go test -cover` with at most four packages and eight tests at `test` stage runs `go test -cover` on four packages at a time, each running at
a time and a 90 second timeout per package. On a failure it runs only the failed most eight tests in parallel, with a 90 second timeout per package. On a failure
tests again with `-v`. it runs only the failed tests again with `-v`.
The image build depends on the `lint`, `test`, `css-check` and `markdown-check` The image build depends on the `lint`, `test`, `css-check` and `markdown-check`
stages, so an image that built is a tree that passed every check. The runtime stages, so an image that built is a tree that passed every check. The runtime
stage is Alpine with `ca-certificates` and `su-exec` added. It has no `sqlite3`. stage is Alpine with `ca-certificates` and `su-exec`, a tool that switches user,
added. It has no `sqlite3`.
The `check` workflow in `.gitea/workflows/check.yml` checks out the repository The `check` workflow in `.gitea/workflows/check.yml` checks out the repository
and runs `script/cibuild`, nothing else. and runs `script/cibuild`, nothing else.
@@ -1352,7 +1511,7 @@ and runs `script/cibuild`, nothing else.
The binary reports its version at `/.well-known/healthcheck`, in the page The binary reports its version at `/.well-known/healthcheck`, in the page
footer, in the `starting` log line and as the Sentry release. The value is footer, in the `starting` log line and as the Sentry release. The value is
stamped by the linker at build time. stamped into the binary at build time.
`script/version` produces it. With `VERSION` set in the environment, that value `script/version` produces it. With `VERSION` set in the environment, that value
is used. Otherwise it is `git describe --tags --always --dirty` on this is used. Otherwise it is `git describe --tags --always --dirty` on this
@@ -1379,14 +1538,15 @@ builds of one commit is stamped in.
### Third-party browser assets ### Third-party browser assets
The pages use one third-party script, Alpine.js, a small JavaScript library, in The pages use one third-party script: Alpine.js, a small JavaScript library.
its CSP build (`@alpinejs/csp`). The Content-Security-Policy forbids `eval`, They use its CSP build, `@alpinejs/csp`, which runs under a
which the standard build needs. With the CSP build, every Alpine directive in Content-Security-Policy that forbids `eval`. The standard build needs `eval`.
`templates/` names a component registered in `static/js/app.js`, such as With the CSP build, every Alpine directive in `templates/` names a component
`x-data="collapsible"`, and never an inline expression. registered in `static/js/app.js`, such as `x-data="collapsible"`, and never an
inline expression.
The npm tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for byte as The npm package file is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for byte
the registry publishes it. `script/assets` extracts `package/dist/cdn.min.js` as the registry publishes it. `script/assets` extracts `package/dist/cdn.min.js`
from it to `static/js/alpine.min.js`, which the binary embeds. `make build`, from it to `static/js/alpine.min.js`, which the binary embeds. `make build`,
`make dev` and the Docker stages run it first. The extracted file is not `make dev` and the Docker stages run it first. The extracted file is not
committed, and a build that skips the extraction fails with an error naming committed, and a build that skips the extraction fails with an error naming
@@ -1397,16 +1557,17 @@ To move to a new version:
1. Download `https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`. 1. Download `https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`.
2. Check it against the `dist.integrity` hash at 2. Check it against the `dist.integrity` hash at
`https://registry.npmjs.org/@alpinejs/csp/<version>`. `https://registry.npmjs.org/@alpinejs/csp/<version>`.
3. Replace the tarball in `3p/` and update the file name in `script/assets`. 3. Replace the file in `3p/` and update the file name in `script/assets`.
4. Run `make check` and `make test-browser`. 4. Run `make check` and `make test-browser`.
### Browser test ### Browser test
`internal/server` has a test that loads the pages in a headless browser, one `internal/server` has a test that loads the pages in a headless browser and
without a window, under the real Content-Security-Policy and drives the drives the interface under the real Content-Security-Policy. A headless browser
interface. It is built only with the `browser` build tag, so `make test` leaves is one without a window. The test is built only with the `browser` build tag, a
it out. `make test-browser` runs it in Docker with a pinned headless browser flag given to the compiler, so `make test` leaves it out. `make test-browser`
image. Run it after changing `templates/` or `static/js/`. runs it in Docker with a pinned headless browser image. Run it after changing
`templates/` or `static/js/`.
### Stylesheet ### Stylesheet
@@ -1419,92 +1580,45 @@ committed file differs from what `make css` generates. `static/css/style.css` is
hand-written. The Content-Security-Policy refuses inline styles, so templates hand-written. The Content-Security-Policy refuses inline styles, so templates
style elements with classes only. style elements with classes only.
### Libraries ## Entrypoints
- [Uber fx](https://go.uber.org/fx) wires the components and runs their start This repository adheres to the
and stop hooks. [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
- [chi](https://github.com/go-chi/chi) routes HTTP. standard: the scripts in `script/` are the entry points for the development
- [GORM](https://gorm.io) with workflow. This section is about those scripts. The receiving URLs of the
[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite), a pure Go SQLite application are under [Entrypoint URLs](#entrypoint-urls).
driver, handles the databases.
- `log/slog` from the standard library writes the logs.
- [gorilla/sessions](https://github.com/gorilla/sessions) and
[gorilla/csrf](https://github.com/gorilla/csrf) handle sessions and form
protection.
- [go-chi/httprate](https://github.com/go-chi/httprate) rate-limits the
receiver, the password change, replay and resubmit.
- [Prometheus](https://prometheus.io) client and
[go-http-metrics](https://github.com/slok/go-http-metrics) serve the metrics.
- [Sentry](https://sentry.io) reports errors when configured.
### Package layout - `script/bootstrap` installs git, make, Go and the Go modules. It is safe to
rerun.
- `script/setup` runs `script/bootstrap`, then `script/install-precommit`.
- `script/projectname` prints `webhooker`, the name the other scripts use.
- `script/assets` extracts Alpine.js from `3p/` to `static/js/alpine.min.js`.
- `script/test` builds the `test` stage of the `Dockerfile`, which runs the
tests.
- `script/test-browser` builds `Dockerfile.browser`, which runs the browser
test.
- `script/lint` builds the `lint` stage, which runs `gofmt`, `golangci-lint` and
ESLint.
- `script/fmt` formats the Go code with `gofmt` and `goimports` on the host, and
the Markdown with prettier in Docker. It writes.
- `script/fmt-check` runs the same checks and fails instead of writing.
- `script/css` regenerates `static/css/tailwind.css` in Docker. It writes.
- `script/css-check` fails when `static/css/tailwind.css` differs from what
`script/css` would generate.
- `script/check` runs `script/test`, `script/lint`, `script/fmt-check` and
`script/css-check`.
- `script/version` prints the version to stamp into the binary.
- `script/docker` builds the image tagged `webhooker`, passing that version in.
- `script/cibuild` is what CI runs: `script/bootstrap`, then `script/check`,
then the same image build as `script/docker`.
- `script/precommit` checks that `go mod tidy` changes nothing, then runs
`script/check`.
- `script/install-precommit` installs the git pre-commit hook that runs
`script/precommit`.
All application code is under `internal/`. The entry point is ## TODO
`cmd/webhooker/main.go`, which dispatches subcommands, takes the `DATA_DIR` lock
and builds the fx graph.
- `internal/archive`: archive files, rotation, pruning, renaming and download. See [`TODO.md`](TODO.md).
- `internal/banner`: the ruled box that prints a credential.
- `internal/config`: configuration from the environment and `.env`.
- `internal/database`: the main database, the models, Argon2id, the retention
sweep and the per-webhook database manager.
- `internal/datadir`: the exclusive lock on `DATA_DIR`.
- `internal/db/migrations`: the SQL schema files and their application.
- `internal/delivery`: the engine, the four target types, retries, the circuit
breaker and the destination guard.
- `internal/globals`: build-time variables.
- `internal/gormlog`: GORM's logger on top of `slog`, with values withheld.
- `internal/handlers`: every HTTP handler and page.
- `internal/healthcheck`: the health check response.
- `internal/lifecycle`: the bounded wait used by stop hooks.
- `internal/logfield`: the size budget for client-supplied log values.
- `internal/logger`: `slog` setup and the fx event logger.
- `internal/metrics`: the delivery metrics.
- `internal/middleware`: logging, security headers, CORS, sessions, CSRF, rate
limits, body caps, metrics and panic recovery.
- `internal/referer`: cuts a `Referer` to its scheme and host.
- `internal/reqtls`: the one predicate that decides whether a request is TLS.
- `internal/resetpw`: the `webhooker resetpw` subcommand.
- `internal/server`: the HTTP server, routes, middleware order and Sentry.
- `internal/session`: the cookie session.
- `internal/versionscript`: tests for `script/version` and the build files.
- `static/`: embedded CSS and JavaScript. `templates/`: the HTML templates.
### Components and middleware
fx builds each component when another needs it. It runs their start hooks in
that order and their stop hooks in reverse. The components are:
- the globals, the logger and the config;
- the main database and the per-webhook database manager;
- the retention sweep and the health check;
- the session and the handlers;
- the metrics registry and collectors;
- the middleware and the destination guard;
- the delivery engine, the archive writers and the archive sweep;
- the server.
The global middleware runs in this order:
1. request id;
2. security headers;
3. access log;
4. metrics, when `/metrics` is served;
5. CORS;
6. a 60 second request timeout;
7. panic recovery;
8. Sentry, when configured.
Each page route group then adds its own, in this order:
1. panic recovery;
2. the 1 MB body cap;
3. CSRF;
4. `Cache-Control: no-store`;
5. the sign-in check, in every group except `/pages`, which serves the sign-in
form.
The body cap comes before CSRF because CSRF parses the form.
## License ## License