Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98af5afb9d |
@@ -1,32 +1,20 @@
|
||||
# webhooker
|
||||
|
||||
webhooker is an MIT-licensed web service, written in [Go](https://golang.org) by
|
||||
[@sneak](https://sneak.berlin), that receives the HTTP notifications other
|
||||
services send and forwards them to your own systems.
|
||||
webhooker receives webhooks, stores them, and forwards them to the places you
|
||||
choose. A webhook is an HTTP request that one service sends to another when
|
||||
something happens. Senders usually make one attempt and keep no record. If your
|
||||
receiver is down at that moment, the event is lost.
|
||||
|
||||
Many services send an HTTP request to a URL you give them when something
|
||||
happens: a code push, a payment, a new ticket. webhooker receives those
|
||||
requests, stores them, and forwards them to the places you choose.
|
||||
webhooker sits between the sender and your receiver. It is a single Go binary
|
||||
with a web interface and SQLite storage. It accepts every event the sender posts
|
||||
and writes it to disk before answering. It then delivers the event to each
|
||||
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 a single binary with a web interface. It stores its data in SQLite,
|
||||
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.
|
||||
webhooker is written in [Go](https://golang.org) by
|
||||
[@sneak](https://sneak.berlin). The license is MIT.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -38,9 +26,9 @@ Five words name the moving parts.
|
||||
it. The UUID is a random 36-character identifier, and it is the only thing
|
||||
that authorises a sender.
|
||||
- A **target** is a place events are delivered to. There are four kinds: an HTTP
|
||||
URL, a Slack or Mattermost message-posting URL, an archive database file, and
|
||||
the application log.
|
||||
- An **event** is one stored notification: its method, query string, headers and
|
||||
URL, a Slack or Mattermost incoming webhook, an archive database file, and the
|
||||
application log.
|
||||
- An **event** is one stored request: its method, query string, headers and
|
||||
body.
|
||||
- A **delivery** is one event paired with one target. It records every attempt.
|
||||
|
||||
@@ -54,9 +42,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
|
||||
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
|
||||
targets tolerate a duplicate.
|
||||
receiver tolerate a duplicate.
|
||||
|
||||
## Getting Started
|
||||
## Quick start
|
||||
|
||||
You need the Go version named in `go.mod`, and Docker for the checks.
|
||||
|
||||
@@ -125,7 +113,7 @@ target named `HTTP`. The **Archive** checkbox creates an archive target named
|
||||
|
||||
Every new webhook gets one entrypoint, described as `Default entrypoint`.
|
||||
|
||||
### Entrypoint URLs
|
||||
### Entrypoints
|
||||
|
||||
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
|
||||
@@ -150,11 +138,10 @@ events arrived on it within the retention period.
|
||||
|
||||
- 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 `slack` target posts each event as a message to Slack or Mattermost. It
|
||||
takes the message-posting URL those services give out, which they call an
|
||||
incoming webhook.
|
||||
- A `slack` target posts each event as a message to a Slack or Mattermost
|
||||
incoming webhook URL.
|
||||
- An archive target writes each event as a row in a SQLite file of its own. Its
|
||||
stored type is `database`. It takes an expiry and a rotation.
|
||||
type is `database` in the data. It takes an expiry and a rotation.
|
||||
- A `log` target writes each event to the application log. It has no settings
|
||||
beyond its name.
|
||||
|
||||
@@ -183,9 +170,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.
|
||||
|
||||
Below that are events, deliveries and failures, both lifetime and within
|
||||
retention. Next are figures for the last 10 minutes and the last 24 hours:
|
||||
events, failures and the failure percentage. The percentage is failed deliveries
|
||||
out of all deliveries that finished in the window.
|
||||
retention. Then come the last 10 minutes and the last 24 hours: events,
|
||||
failures, and the failure percentage. The percentage is failed deliveries out of
|
||||
all deliveries that finished in the window.
|
||||
|
||||
The webhook list shows three of these figures per webhook: events within
|
||||
retention, the last event, and failed deliveries in the last 24 hours.
|
||||
@@ -253,7 +240,7 @@ docker run -d \
|
||||
webhooker
|
||||
```
|
||||
|
||||
The image listens on every IPv4 address inside the container: it ships
|
||||
The image listens on every address inside the container: it ships
|
||||
`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`
|
||||
flag decides what the host exposes. Publish to `127.0.0.1:8080`, the address
|
||||
@@ -292,8 +279,8 @@ docker run --rm -v webhooker-data:/var/lib/webhooker \
|
||||
|
||||
### Running under upaas
|
||||
|
||||
[upaas](https://git.eeqj.de/sneak/upaas), a deployment tool, builds the image
|
||||
from this repository's `Dockerfile` and runs it.
|
||||
[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this repository's
|
||||
`Dockerfile` and runs it.
|
||||
|
||||
- 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
|
||||
@@ -325,15 +312,13 @@ docker start upaas-webhooker
|
||||
|
||||
### Behind a reverse proxy
|
||||
|
||||
webhooker speaks plain HTTP. It does not terminate TLS, the encryption behind
|
||||
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.
|
||||
webhooker speaks plain HTTP and terminates no TLS. Put a reverse proxy in front
|
||||
of it. The proxy holds the certificate and reaches webhooker over loopback.
|
||||
|
||||
Five things must be right.
|
||||
|
||||
1. **Keep the app port private.** The binary binds `127.0.0.1` by default. The
|
||||
image binds every IPv4 address inside its container, so publish its port to
|
||||
image binds every 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
|
||||
without TLS.
|
||||
2. **Send `X-Forwarded-Proto: https`.** This header tells webhooker the request
|
||||
@@ -342,9 +327,9 @@ Five things must be right.
|
||||
webhooker reads this header from any peer, so the proxy must overwrite
|
||||
whatever the client sent.
|
||||
3. **Cover the proxy in `TRUSTED_PROXIES`.** Otherwise every client shares one
|
||||
rate-limit bucket. A bucket is the count a limit keeps per client, and here
|
||||
it would be keyed on the proxy's address. The default list is the private
|
||||
ranges, which does not cover a proxy that connects over loopback.
|
||||
rate-limit bucket, the count kept per client, keyed on the proxy's address.
|
||||
The default list is the private ranges, which does not cover a proxy that
|
||||
connects over loopback.
|
||||
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
|
||||
than 443 every form submission then fails with `403`.
|
||||
@@ -391,7 +376,7 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Above webhooker's own 60 second request timeout, so its
|
||||
# 504 reaches the client before nginx gives up.
|
||||
# 503 reaches the client instead of nginx's 504.
|
||||
proxy_read_timeout 70s;
|
||||
}
|
||||
}
|
||||
@@ -480,9 +465,8 @@ 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
|
||||
changes land in a `-wal` file beside the database before they reach the `.db`
|
||||
itself. So a running service has two more files beside each `{name}.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`
|
||||
itself. So a running service has `{name}.db-wal` and `{name}.db-shm` beside each
|
||||
`{name}.db`. 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.
|
||||
|
||||
A clean shutdown closes every database and removes its `-wal` and `-shm` files.
|
||||
@@ -570,31 +554,21 @@ What that means in practice:
|
||||
access log does hold it, in the request path.
|
||||
- To rotate, create a new entrypoint and point the sender at it. Then deactivate
|
||||
or delete the old one.
|
||||
- Some senders cannot be given a secret URL: they post to one fixed address and
|
||||
prove themselves with a signature instead. webhooker checks no signatures, so
|
||||
such a sender cannot use it. Pick another integration path for it.
|
||||
- A sender that can only sign payloads to a fixed URL is a limit of that sender.
|
||||
Pick another integration path for it.
|
||||
|
||||
### The web interface
|
||||
|
||||
- Passwords are hashed with Argon2id, a password hashing function designed to be
|
||||
slow and to need a large amount of memory. The parameters are:
|
||||
- 64 MB of memory;
|
||||
- 1 pass;
|
||||
- 4 threads;
|
||||
- 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.
|
||||
- Passwords are hashed with Argon2id, a memory-hard password hashing function,
|
||||
using 64 MB of memory, 1 pass, 4 threads, a 32-byte key and a 16-byte salt.
|
||||
- Session cookies are signed, `HttpOnly` and `SameSite=Lax`. They carry `Secure`
|
||||
when the request arrived over TLS, judged per request from a direct TLS
|
||||
connection or `X-Forwarded-Proto: https`.
|
||||
- The session is regenerated at sign-in, which prevents session fixation.
|
||||
- The key that signs the cookies is 32 random bytes made on first start and
|
||||
stored in `webhooker.db`.
|
||||
- Every form is protected against cross-site request forgery (CSRF), where
|
||||
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
|
||||
- Every form is protected against cross-site request forgery with a token tied
|
||||
to a cookie. Over TLS the form's `Origin` or `Referer` must also match the
|
||||
host.
|
||||
- Form bodies are capped at 1 MB. A larger declared body is answered `413`
|
||||
before anything reads it.
|
||||
@@ -602,7 +576,7 @@ What that means in practice:
|
||||
- `Strict-Transport-Security`, unless `HSTS_MAX_AGE` is `0`;
|
||||
- `X-Content-Type-Options: nosniff`;
|
||||
- `X-Frame-Options: DENY`;
|
||||
- `Content-Security-Policy` (CSP), which tells the browser where scripts and
|
||||
- `Content-Security-Policy`, which tells the browser where scripts and
|
||||
styles may come from, set to
|
||||
`default-src 'self'; script-src 'self'; style-src 'self'`;
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin`;
|
||||
@@ -648,8 +622,7 @@ the proxy.
|
||||
|
||||
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
|
||||
forgery. webhooker refuses it by default, with a check called the destination
|
||||
check below.
|
||||
forgery. webhooker refuses it by default.
|
||||
|
||||
Every target URL must use `http` or `https` and resolve to a public address.
|
||||
These private and reserved ranges are refused:
|
||||
@@ -662,22 +635,20 @@ These private and reserved ranges are refused:
|
||||
- link-local space, `169.254.0.0/16` and `fe80::/10`, which is valid only on one
|
||||
network segment;
|
||||
- the IPv6 unique local range, `fc00::/7`;
|
||||
- multicast, the ranges reserved for documentation, and the other reserved
|
||||
ranges.
|
||||
- multicast, documentation space and the other reserved ranges.
|
||||
|
||||
One public address is refused too: `168.63.129.16`, the Azure service that hands
|
||||
a virtual machine its credentials.
|
||||
a VM its credentials.
|
||||
|
||||
The destination check runs when a target is saved and again when each delivery
|
||||
connects. Both use the same function. The address is resolved again at connect
|
||||
time, so a hostname that changes its address after validation is still checked.
|
||||
The check runs when a target is saved and again when each delivery connects.
|
||||
Both use the same function. The address is resolved again at connect 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
|
||||
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
|
||||
host, a different port, or a step from `https` to `http`. This stops an open
|
||||
redirect, a destination that forwards requests to any URL a caller names, from
|
||||
handing a credential to another host.
|
||||
redirect from handing a credential to another host.
|
||||
|
||||
`ALLOWED_EGRESS_CIDRS` opens named networks for the common case of forwarding to
|
||||
your own network. Read
|
||||
@@ -686,19 +657,18 @@ before setting it.
|
||||
|
||||
### What the logs contain
|
||||
|
||||
webhooker writes structured logs, one record per line with named fields, to
|
||||
standard output: plain text on a terminal, JSON otherwise. Each request writes
|
||||
one access log line, `http request`, at `INFO`. A request to a known entrypoint
|
||||
writes a second line, `webhook request received`. A stored event writes a third,
|
||||
webhooker writes structured logs to standard output: plain text on a terminal,
|
||||
JSON otherwise. Each request writes one access log line, `http request`, at
|
||||
`INFO`. A request to a known entrypoint writes a second line,
|
||||
`webhook request received`. A stored event writes a third,
|
||||
`webhook event created`.
|
||||
|
||||
Fields a client can influence are cut to a fixed size, so one access log line is
|
||||
at most 2,560 bytes. The access log never records the query string. On the
|
||||
receiver route the URL is logged as `/h/{uuid}`, never the real path, so
|
||||
entrypoint UUIDs stay out of the log. On other routes a `3xx` or `4xx` response
|
||||
logs the route pattern, which is the route as registered, such as
|
||||
`/user/{username}`. A `2xx` or `5xx` logs the real path. The `Referer` is logged
|
||||
as its scheme and host only.
|
||||
at most 2,560 bytes. The query string is never logged. On the receiver route the
|
||||
URL is logged as `/h/{uuid}`, never the real path, so entrypoint UUIDs stay out
|
||||
of the log. On other routes a `3xx` or `4xx` response logs the route pattern,
|
||||
which is the route as registered, such as `/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
|
||||
peer, which behind a proxy is the proxy. `clientIP` is the client as decided by
|
||||
@@ -712,13 +682,11 @@ 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
|
||||
hostnames.
|
||||
|
||||
A `log` target writes the whole event to the log: query string, headers and
|
||||
body. That is its purpose.
|
||||
A `log` target writes the whole event, headers and body, to the log. That is its
|
||||
purpose.
|
||||
|
||||
A recovered panic is a crash inside one request that the server caught. 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.
|
||||
A recovered panic writes one `ERROR` record of at most 10,240 bytes, with the
|
||||
request id of the request that caused it.
|
||||
|
||||
When `SENTRY_DSN` is set, error reports go to Sentry, an error reporting
|
||||
service, with the request reduced to this:
|
||||
@@ -729,114 +697,6 @@ service, with the request reduced to this:
|
||||
- only these headers: `Accept`, `Content-Length`, `Content-Type`, `Host`,
|
||||
`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
|
||||
|
||||
All configuration comes from environment variables. For development, variables
|
||||
@@ -854,8 +714,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
|
||||
differences. `TRUSTED_PROXIES` and `ALLOWED_EGRESS_CIDRS` appear as counts.
|
||||
`SENTRY_DSN` appears as on or off. The metrics credentials appear as one
|
||||
on-or-off field. The `/settings` page shows every value; there,
|
||||
`SENTRY_DSN` and the metrics credentials appear as on or off. `METRICS_USERNAME`
|
||||
does not appear. The `/settings` page shows every value; there,
|
||||
`METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set.
|
||||
|
||||
### `WEBHOOKER_ENVIRONMENT`
|
||||
@@ -939,10 +799,9 @@ startup fails with an error naming both variables.
|
||||
|
||||
### `SENTRY_DSN`
|
||||
|
||||
The DSN for Sentry error reporting: the URL that tells the Sentry client where
|
||||
to send reports. Default empty, which leaves reporting off. A set value is
|
||||
parsed with the Sentry client's own parser, and one it cannot parse aborts
|
||||
startup. The release name sent to Sentry is `webhooker-{version}`.
|
||||
A Sentry DSN for error reporting. Default empty, which leaves reporting off. A
|
||||
set value is parsed with the Sentry SDK's own parser, and one it cannot parse
|
||||
aborts startup. The release name sent to Sentry is `webhooker-{version}`.
|
||||
|
||||
### `RETENTION_SWEEP_INTERVAL`
|
||||
|
||||
@@ -989,11 +848,10 @@ 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
|
||||
value replaces the default.
|
||||
|
||||
The list decides whose `X-Forwarded-For` header is believed. `X-Forwarded-For`
|
||||
is the header a proxy uses to pass on the address of the client it is serving.
|
||||
When the connecting peer is inside the list, the client address is read from
|
||||
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 list decides whose `X-Forwarded-For` header is believed. When the connecting
|
||||
peer is inside the list, the client address is read from 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
|
||||
trusted proxy is the client. A hop that is not a bare IP address ends the walk,
|
||||
@@ -1031,9 +889,8 @@ 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.
|
||||
|
||||
Some addresses stay blocked whatever you list. They are link-local space, the
|
||||
unspecified addresses `0.0.0.0` and `::`, and cloud metadata endpoints at fixed
|
||||
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:
|
||||
unspecified addresses, and cloud metadata endpoints at fixed non-public
|
||||
addresses that hand out credentials:
|
||||
|
||||
- `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
|
||||
@@ -1082,14 +939,10 @@ These event headers are never forwarded:
|
||||
An `http` target's settings:
|
||||
|
||||
- `url`, the destination. Only `http` and `https` are accepted.
|
||||
- `headers`, one `Name: value` per line. `Content-Type` is allowed and replaces
|
||||
the event's. These names are refused, because the delivery sets them itself:
|
||||
- `Host`
|
||||
- `Content-Length`
|
||||
- `Transfer-Encoding`
|
||||
- `Connection`
|
||||
- `Trailer`
|
||||
- `User-Agent`
|
||||
- `headers`, one `Name: value` per line. `Host`, `Content-Length`,
|
||||
`Transfer-Encoding`, `Connection`, `Trailer` and `User-Agent` are refused,
|
||||
because the delivery sets them itself. `Content-Type` is allowed and replaces
|
||||
the event's.
|
||||
- `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
|
||||
for its whole duration.
|
||||
@@ -1099,8 +952,8 @@ An `http` target's settings:
|
||||
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
|
||||
the body in a code block. A JSON body is indented. A body over 3500 bytes is
|
||||
cut. The URL is any message-posting URL that accepts Slack's format, which
|
||||
includes Mattermost's.
|
||||
cut. The URL is any Slack-compatible incoming webhook URL, which includes
|
||||
Mattermost.
|
||||
|
||||
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
|
||||
@@ -1132,11 +985,10 @@ circuit breaker stops hammering a destination that is down. It is in memory only
|
||||
and resets when the service restarts.
|
||||
|
||||
The breaker is closed in normal operation. After 5 failures in a row it opens
|
||||
for 30 seconds. That wait is the cooldown. While the breaker is open, each
|
||||
delivery to the target is marked `retrying` and scheduled for when the cooldown
|
||||
ends. When the cooldown ends, one delivery is sent as a probe. The others wait
|
||||
one more cooldown. If the probe succeeds, the breaker closes. If it fails, the
|
||||
breaker opens again.
|
||||
for 30 seconds. While it is open, each delivery to the target is marked
|
||||
`retrying` and scheduled for when the cooldown ends. When the cooldown ends, one
|
||||
delivery is sent as a probe. The others wait 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
|
||||
waiting delivery in the event log shows the earliest time it can be tried.
|
||||
@@ -1173,10 +1025,10 @@ the delivery `failed` and records the reason.
|
||||
### Shutdown
|
||||
|
||||
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,
|
||||
which means it waits for them to finish, for at most 3 seconds. That leaves 2
|
||||
seconds for the components after it. A Sentry flush, if enabled, fits inside the
|
||||
same budget and is skipped when little is left.
|
||||
second budget for the whole sequence. The HTTP server drains open requests for
|
||||
at most 3 seconds, leaving 2 seconds for the components after it. A Sentry
|
||||
flush, if enabled, fits inside the same budget and is skipped when little is
|
||||
left.
|
||||
|
||||
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
|
||||
@@ -1186,9 +1038,8 @@ 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
|
||||
database is not closed cleanly.
|
||||
|
||||
Keep the container's stop grace period above 5 seconds. That is the time Docker
|
||||
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.
|
||||
Keep the container's stop grace period above 5 seconds. Docker's default is 10.
|
||||
A shorter grace kills the process before the shutdown can run.
|
||||
|
||||
### Archives
|
||||
|
||||
@@ -1248,9 +1099,9 @@ entrypoints and targets. Each `events-{webhook_uuid}.db` holds that webhook's
|
||||
event tier: events, deliveries, delivery attempts and running totals. Each
|
||||
`archive-*.db` holds one archive target's rows.
|
||||
|
||||
Every database is opened through one code path, in WAL mode, with at most 4
|
||||
connections. A connection waits up to 10 seconds for a locked database before
|
||||
giving up. Under WAL a reader never blocks a writer.
|
||||
Every database is opened through one code path, in WAL mode, with a 10 second
|
||||
busy timeout and at most 4 connections. Under WAL a reader never blocks a
|
||||
writer.
|
||||
|
||||
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
|
||||
@@ -1260,9 +1111,8 @@ that line means the file was lost, most often because the volume holding
|
||||
`ERROR` naming it and a `500` to its senders.
|
||||
|
||||
Deleting a webhook soft-deletes its row, entrypoints and targets in
|
||||
`webhooker.db` and deletes its event database file. A soft delete marks the row
|
||||
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.
|
||||
`webhooker.db` and deletes its event database file. Deleting an entrypoint or a
|
||||
target soft-deletes it.
|
||||
|
||||
### Schema
|
||||
|
||||
@@ -1276,7 +1126,8 @@ transaction.
|
||||
### Application tier
|
||||
|
||||
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.
|
||||
`updated_at` and `deleted_at`. A row with `deleted_at` set is soft-deleted and
|
||||
hidden from the application.
|
||||
|
||||
`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.
|
||||
@@ -1317,25 +1168,18 @@ bare UUID. The `/h/` prefix is route only and is not stored.
|
||||
`Delivery` has `event_id`, `target_id`, `status`, `finished_at` and `replay`.
|
||||
`finished_at` is set when the status becomes `delivered` or `failed`.
|
||||
|
||||
`DeliveryResult` is one attempt. It has these fields:
|
||||
`DeliveryResult` is one attempt. It has `delivery_id`, `attempt_num`, `success`,
|
||||
`status_code`, `response_body` (the first 4 KiB), `error` and `duration` in
|
||||
milliseconds.
|
||||
|
||||
- `delivery_id` and `attempt_num`;
|
||||
- `success` and `status_code`;
|
||||
- `response_body`, the first 4 KiB;
|
||||
- `error`;
|
||||
- `duration`, in milliseconds.
|
||||
|
||||
Three tables hold running totals so the pages need not count rows. Each count
|
||||
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.
|
||||
Three tables hold running totals so the pages need not count rows. `EventTotals`
|
||||
is one row: `events`, `events_removed` and `last_event_at`. `TargetTotals` is
|
||||
one row per target: `deliveries`, `delivered`, `failed`, `deliveries_removed`
|
||||
and `failed_removed`. `EntrypointTotals` is one row per entrypoint:
|
||||
`last_event_at`. Each count changes in the transaction that writes or deletes
|
||||
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
|
||||
have expired.
|
||||
|
||||
## HTTP routes reference
|
||||
|
||||
@@ -1393,10 +1237,8 @@ set, behind basic authentication.
|
||||
|
||||
## Metrics reference
|
||||
|
||||
`/metrics` serves one Prometheus registry, the set of metrics it exposes. It
|
||||
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.
|
||||
`/metrics` serves one Prometheus registry. It carries the Go runtime and process
|
||||
collectors, the delivery metrics below, and three inbound HTTP metrics.
|
||||
|
||||
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
|
||||
@@ -1429,8 +1271,7 @@ The inbound HTTP metrics are `http_request_duration_seconds`,
|
||||
`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
|
||||
matched no route carries `(unmatched)`. `http_requests_inflight` is one series
|
||||
(one metric with one set of label values) for the whole service, with `handler`
|
||||
set to `(all)`.
|
||||
for the whole service, with `handler` set to `(all)`.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -1441,14 +1282,13 @@ set to `(all)`.
|
||||
Docker.
|
||||
|
||||
`gofmt` and `goimports` run on the host. These tools run only inside Docker, at
|
||||
the versions pinned (fixed to one exact release) in the `Dockerfile`,
|
||||
`package.json` and `yarn.lock`:
|
||||
the versions pinned in the `Dockerfile`, `package.json` and `yarn.lock`:
|
||||
|
||||
- `golangci-lint`, the Go linter;
|
||||
- ESLint, the JavaScript linter;
|
||||
- prettier, the Markdown formatter;
|
||||
- node and yarn, which run the two above;
|
||||
- the Tailwind CLI, which generates the stylesheet.
|
||||
- `golangci-lint`;
|
||||
- ESLint;
|
||||
- prettier;
|
||||
- node and yarn;
|
||||
- the Tailwind CLI.
|
||||
|
||||
### Commands
|
||||
|
||||
@@ -1474,35 +1314,36 @@ make css-check # Fail if static/css/tailwind.css is out of date
|
||||
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
|
||||
`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.
|
||||
The scripts are listed under [Entrypoints](#entrypoints).
|
||||
`script/cibuild` is what CI runs: `script/bootstrap`, then `script/check`, then
|
||||
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,
|
||||
which finds unsynchronised access to shared memory, needs cgo, so the tests run
|
||||
without it.
|
||||
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.
|
||||
|
||||
### How the checks run
|
||||
|
||||
Each check is a stage of the `Dockerfile`, which is one named step of the image
|
||||
build. `script/test` builds the `test` stage, `script/lint` the `lint` stage and
|
||||
`script/css-check` the `css-check` stage. `script/fmt-check` runs `gofmt` on the
|
||||
host and builds the `markdown-check` stage. Every `docker build` in `script/`
|
||||
passes `--no-cache`, so Docker reruns every step instead of reusing an earlier
|
||||
result, and a check that reports success ran. A bare `docker build .` carries no
|
||||
such guarantee.
|
||||
Each check is a stage of the `Dockerfile`. `script/test` builds the `test`
|
||||
stage, `script/lint` the `lint` stage and `script/css-check` the `css-check`
|
||||
stage. `script/fmt-check` runs `gofmt` on the host and builds the
|
||||
`markdown-check` stage. Every `docker build` in `script/` passes `--no-cache`,
|
||||
so 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
|
||||
`golangci-lint run` with `--network=none`, and ESLint over `static/js/`. The
|
||||
`test` stage runs `go test -cover` on four packages at a time, each running at
|
||||
most eight tests in parallel, with a 90 second timeout per package. On a failure
|
||||
it runs only the failed tests again with `-v`.
|
||||
`test` stage runs `go test -cover` with at most four packages and eight tests at
|
||||
a time and a 90 second timeout per package. On a failure it runs only the failed
|
||||
tests again with `-v`.
|
||||
|
||||
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
|
||||
stage is Alpine with `ca-certificates` and `su-exec`, a tool that switches user,
|
||||
added. It has no `sqlite3`.
|
||||
stage is Alpine with `ca-certificates` and `su-exec` added. It has no `sqlite3`.
|
||||
|
||||
The `check` workflow in `.gitea/workflows/check.yml` checks out the repository
|
||||
and runs `script/cibuild`, nothing else.
|
||||
@@ -1511,7 +1352,7 @@ and runs `script/cibuild`, nothing else.
|
||||
|
||||
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
|
||||
stamped into the binary at build time.
|
||||
stamped by the linker at build time.
|
||||
|
||||
`script/version` produces it. With `VERSION` set in the environment, that value
|
||||
is used. Otherwise it is `git describe --tags --always --dirty` on this
|
||||
@@ -1538,15 +1379,14 @@ builds of one commit is stamped in.
|
||||
|
||||
### Third-party browser assets
|
||||
|
||||
The pages use one third-party script: Alpine.js, a small JavaScript library.
|
||||
They use its CSP build, `@alpinejs/csp`, which runs under a
|
||||
Content-Security-Policy that forbids `eval`. The standard build needs `eval`.
|
||||
With the CSP build, every Alpine directive in `templates/` names a component
|
||||
registered in `static/js/app.js`, such as `x-data="collapsible"`, and never an
|
||||
inline expression.
|
||||
The pages use one third-party script, Alpine.js, a small JavaScript library, in
|
||||
its CSP build (`@alpinejs/csp`). The Content-Security-Policy forbids `eval`,
|
||||
which the standard build needs. With the CSP build, every Alpine directive in
|
||||
`templates/` names a component registered in `static/js/app.js`, such as
|
||||
`x-data="collapsible"`, and never an inline expression.
|
||||
|
||||
The npm package file is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for byte
|
||||
as the registry publishes it. `script/assets` extracts `package/dist/cdn.min.js`
|
||||
The npm tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for byte 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`,
|
||||
`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
|
||||
@@ -1557,17 +1397,16 @@ To move to a new version:
|
||||
1. Download `https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`.
|
||||
2. Check it against the `dist.integrity` hash at
|
||||
`https://registry.npmjs.org/@alpinejs/csp/<version>`.
|
||||
3. Replace the file in `3p/` and update the file name in `script/assets`.
|
||||
3. Replace the tarball in `3p/` and update the file name in `script/assets`.
|
||||
4. Run `make check` and `make test-browser`.
|
||||
|
||||
### Browser test
|
||||
|
||||
`internal/server` has a test that loads the pages in a headless browser and
|
||||
drives the interface under the real Content-Security-Policy. A headless browser
|
||||
is one without a window. The test is built only with the `browser` build tag, a
|
||||
flag given to the compiler, so `make test` leaves it out. `make test-browser`
|
||||
runs it in Docker with a pinned headless browser image. Run it after changing
|
||||
`templates/` or `static/js/`.
|
||||
`internal/server` has a test that loads the pages in a headless browser, one
|
||||
without a window, under the real Content-Security-Policy and drives the
|
||||
interface. It is built only with the `browser` build tag, so `make test` leaves
|
||||
it out. `make test-browser` runs it in Docker with a pinned headless browser
|
||||
image. Run it after changing `templates/` or `static/js/`.
|
||||
|
||||
### Stylesheet
|
||||
|
||||
@@ -1580,45 +1419,92 @@ committed file differs from what `make css` generates. `static/css/style.css` is
|
||||
hand-written. The Content-Security-Policy refuses inline styles, so templates
|
||||
style elements with classes only.
|
||||
|
||||
## Entrypoints
|
||||
### Libraries
|
||||
|
||||
This repository adheres to the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
standard: the scripts in `script/` are the entry points for the development
|
||||
workflow. This section is about those scripts. The receiving URLs of the
|
||||
application are under [Entrypoint URLs](#entrypoint-urls).
|
||||
- [Uber fx](https://go.uber.org/fx) wires the components and runs their start
|
||||
and stop hooks.
|
||||
- [chi](https://github.com/go-chi/chi) routes HTTP.
|
||||
- [GORM](https://gorm.io) with
|
||||
[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite), a pure Go SQLite
|
||||
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.
|
||||
|
||||
- `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`.
|
||||
### Package layout
|
||||
|
||||
## TODO
|
||||
All application code is under `internal/`. The entry point is
|
||||
`cmd/webhooker/main.go`, which dispatches subcommands, takes the `DATA_DIR` lock
|
||||
and builds the fx graph.
|
||||
|
||||
See [`TODO.md`](TODO.md).
|
||||
- `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 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user