check / check (push) Waiting to run
Rewritten from scratch against the code on next. The old text was the list of what a reader needs covered. Model: fable-5-1
745 lines
33 KiB
Markdown
745 lines
33 KiB
Markdown
# webhooker
|
|
|
|
webhooker is an MIT-licensed [Go](https://golang.org) web service by
|
|
[@sneak](https://sneak.berlin) that receives the HTTP requests other services
|
|
send and forwards them to your own systems.
|
|
|
|
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 each one as an event, and forwards it to the places you choose.
|
|
It 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.
|
|
|
|
## Rationale
|
|
|
|
webhooker exists so that a request from another service is not lost while your
|
|
service is down. It also exists so that you can see what was sent and what
|
|
happened to it.
|
|
|
|
It stores each event before it forwards it. It keeps events and their delivery
|
|
attempts for the retention period you set. It can forward one event to several
|
|
targets, and it can send a stored event again.
|
|
|
|
## How it works
|
|
|
|
Five words name the moving parts.
|
|
|
|
- A **webhook** is a named configuration. It groups entrypoints and targets, and
|
|
it owns the events it receives.
|
|
- An **entrypoint** is a receiving URL of the form `/h/{uuid}`. Senders post to
|
|
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, the message-posting URL of a chat service (Slack or Mattermost), an
|
|
archive 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.
|
|
|
|
The part of webhooker that answers requests to `/h/{uuid}` is the receiver. It
|
|
stores the event and one pending delivery per active target, then answers `200`.
|
|
|
|
Delivery is at least once, not exactly once. A delivery can reach its target
|
|
more than 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.
|
|
|
|
## Getting Started
|
|
|
|
You need the Go version named in `go.mod`, and Docker for the checks.
|
|
|
|
```bash
|
|
git clone https://git.eeqj.de/sneak/webhooker.git
|
|
cd webhooker
|
|
make bootstrap
|
|
DATA_DIR=../webhooker-data make dev
|
|
```
|
|
|
|
`DATA_DIR` is where webhooker keeps its databases. It defaults to
|
|
`/var/lib/webhooker`, so point it at a directory you can write to. On a host
|
|
without Docker, `make build` writes the program to `bin/webhooker`, and running
|
|
that file starts webhooker.
|
|
|
|
The first start creates the `admin` account and prints its password once, in a
|
|
ruled box on standard output:
|
|
|
|
```
|
|
========================================================================
|
|
WEBHOOKER FIRST BOOT: an admin account has been created.
|
|
|
|
username: admin
|
|
password: 3xamPl3-p4ssw0rd
|
|
|
|
Save this password now: it is shown only here, and only once.
|
|
If it is lost, run `webhooker resetpw admin` on a stopped deployment.
|
|
========================================================================
|
|
```
|
|
|
|
Open `http://127.0.0.1:8080/` and sign in. The server listens on `127.0.0.1`
|
|
port `8080` by default.
|
|
|
|
To run it in Docker instead, build the image from the clone with `make docker`,
|
|
which tags it `webhooker`. Then:
|
|
|
|
```bash
|
|
docker run -d \
|
|
-p 127.0.0.1:8080:8080 \
|
|
-v /path/to/data:/var/lib/webhooker \
|
|
webhooker
|
|
```
|
|
|
|
The first-boot password goes to the container's log. Read it with `docker logs`
|
|
and the container's name.
|
|
|
|
In production, put a reverse proxy in front of webhooker: a server that accepts
|
|
the public connections and passes them on. Read
|
|
[Behind a reverse proxy](#behind-a-reverse-proxy) first.
|
|
|
|
## Using the web interface
|
|
|
|
The sign-in page is `/pages/login`. There is one account, `admin`, and no way to
|
|
add another through the web interface. A session lasts up to 7 days. It ends
|
|
sooner after 24 hours without use, unless `SESSION_IDLE_TIMEOUT` says otherwise.
|
|
|
|
### Webhooks
|
|
|
|
On the webhook list at `/hooks`, **New Webhook** asks for **Name**, an optional
|
|
**Description** and **Retention (days)**. Events older than the retention period
|
|
are deleted, along with their deliveries. The default is 30 days. `0` keeps
|
|
events forever. The form also has an **HTTP target URL** field and an
|
|
**Archive** checkbox; each creates that target with the webhook. Every new
|
|
webhook gets one entrypoint, described as `Default entrypoint`.
|
|
|
|
### Entrypoint URLs
|
|
|
|
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
|
|
like a password.
|
|
|
|
**Add** creates another entrypoint with its own URL. **Edit** changes an
|
|
entrypoint's description. Its URL never changes. To replace a URL, create a new
|
|
entrypoint, point the sender at it, then deactivate or delete the old one. A
|
|
deactivated entrypoint answers `410 Gone`. A deleted or unknown one answers
|
|
`404`.
|
|
|
|
### Targets
|
|
|
|
Under **Targets**, **Add** asks for the type first, then that type's settings.
|
|
|
|
- An `http` target posts each event to a URL, with optional **Headers** and a
|
|
**Timeout**.
|
|
- A `slack` target posts each event as a message. Its **Webhook URL** is the
|
|
message-posting URL Slack or Mattermost 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
|
|
stored type is `database`. **Archive expiry** is how long archived events are
|
|
kept, and **Archive rotation** is how often a new archive file is started.
|
|
- A `log` target writes each event to the application log.
|
|
|
|
`http` and `slack` targets also take **Delivery attempts**: how many times each
|
|
delivery is tried in all. The default is 1. The most is 20. A target's type
|
|
cannot be changed. Deleting an archive target leaves its files on disk.
|
|
**Deactivate** and **Activate** on the webhook page switch a target off and on,
|
|
and new events skip a target that is off.
|
|
|
|
### Events
|
|
|
|
The webhook page shows the 50 newest events. Each expands to show its body. A
|
|
body is shown as text up to 32 KiB. A longer or binary body has a **Download the
|
|
body** link. **Full Event Log** shows the 50 newest events with their deliveries
|
|
and every attempt. **Failed** and **Pending** narrow it to events with a failed
|
|
delivery, or with one still pending or retrying.
|
|
|
|
A finished delivery has a **Replay** button while its target exists. **Replay**
|
|
creates a new delivery of the same event to the same target, with the target's
|
|
settings as they stand now. **Resubmit** sits beside each event. It stores a
|
|
copy of the event as a new event and delivers it to every target that is active
|
|
now. Both actions are limited to 30 per minute per client.
|
|
|
|
### Profile and settings
|
|
|
|
`/user/{username}` lets you change your password. `/settings` lists every
|
|
configuration variable with the value the running server loaded;
|
|
`METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set.
|
|
|
|
## Deploying
|
|
|
|
### Running with Docker
|
|
|
|
No image is published. `make docker` builds one from the clone and tags it
|
|
`webhooker`. Run it as shown under [Getting Started](#getting-started).
|
|
|
|
The image listens on every IPv4 address inside the container: it ships
|
|
`BIND_ADDRESS=0.0.0.0`. The `-p` flag decides what the host exposes. Publish to
|
|
`127.0.0.1:8080`, the address your reverse proxy connects to, and nothing wider.
|
|
|
|
Do not set `BIND_ADDRESS` to an empty value in a container. An empty value means
|
|
unset, and unset falls back to `127.0.0.1`, so the container answers nothing on
|
|
its published port.
|
|
|
|
The app runs as user `webhooker`. The image's start-up script is
|
|
`deploy/docker-entrypoint.sh`. It gives `DATA_DIR` and its contents to that user
|
|
before the app starts. So a host directory can be mounted as it is. webhooker
|
|
creates every SQLite file readable and writable by that user alone (mode
|
|
`0600`).
|
|
|
|
### Running under upaas
|
|
|
|
[upaas](https://git.eeqj.de/sneak/upaas) is a deployment tool. It builds the
|
|
image from this repository's `Dockerfile` and runs it. Add no port mapping,
|
|
because upaas publishes every mapped port on all interfaces of the host. Put the
|
|
app on your reverse proxy's Docker network instead, and mount one host directory
|
|
at `/var/lib/webhooker`. The first-boot password goes to the container's log.
|
|
Read it with `docker logs` and the name upaas gave the container.
|
|
|
|
### Behind a reverse proxy
|
|
|
|
webhooker speaks plain HTTP. It does not handle TLS, the encryption behind
|
|
HTTPS: it holds no certificate and accepts no HTTPS connections. The reverse
|
|
proxy holds the certificate and reaches webhooker over a private address. A bare
|
|
host is a host without Docker. On a bare host that is loopback, the address only
|
|
this host can reach. For the image it is the Docker bridge, the private network
|
|
Docker puts containers on.
|
|
|
|
Four things must be right.
|
|
|
|
1. **Keep the app port private.** Only the proxy may reach it: `127.0.0.1` on a
|
|
bare host, or a port published to `127.0.0.1` for the image.
|
|
2. **Send `X-Forwarded-Proto: https`.** This header tells webhooker the request
|
|
arrived over TLS. It decides the `Secure` flag on cookies, the flag that
|
|
makes a browser send them only over HTTPS. It also decides whether a form
|
|
submission must carry a matching `Origin` or `Referer` header. A browser
|
|
sends those two headers to say which site or page a request came from.
|
|
webhooker reads `X-Forwarded-Proto` from any connecting machine, so the proxy
|
|
must overwrite whatever the client sent.
|
|
3. **Cover the proxy in `TRUSTED_PROXIES`.** Otherwise every client shares one
|
|
rate-limit bucket, the count a limit keeps per client. A proxy on the host
|
|
reaches the container from the Docker bridge address, such as `172.17.0.1`.
|
|
The default list covers it. The list does not cover loopback, so on a bare
|
|
host set `TRUSTED_PROXIES=127.0.0.1`.
|
|
4. **Pass on the client's `Host` header with its port.** `Host` is the header
|
|
that names the site the client asked for. webhooker checks a form's `Origin`
|
|
against the `Host` header it receives. A `Host` without the port makes every
|
|
form submission fail with `403` on any port other than `443`. For the nginx
|
|
reverse proxy that means `$http_host`, not `$host`.
|
|
|
|
Protect the proxy's access log: its request paths hold the entrypoint UUIDs.
|
|
|
|
An nginx server block for a bare host:
|
|
|
|
```nginx
|
|
server {
|
|
listen 443 ssl;
|
|
server_name webhooker.example.com;
|
|
ssl_certificate /etc/ssl/certs/webhooker.example.com.crt;
|
|
ssl_certificate_key /etc/ssl/private/webhooker.example.com.key;
|
|
client_max_body_size 1m;
|
|
|
|
location / {
|
|
proxy_pass http://127.0.0.1:8080;
|
|
proxy_set_header Host $http_host;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_read_timeout 70s;
|
|
}
|
|
}
|
|
```
|
|
|
|
The block matches webhooker's 1 MB body cap. Its `proxy_read_timeout` is longer
|
|
than webhooker's 60 second request timeout, so webhooker's own `504` reaches the
|
|
client first.
|
|
|
|
### HSTS
|
|
|
|
HSTS is a response header that tells browsers to use HTTPS only for this
|
|
hostname and every name under it, for `max-age` seconds. Every response carries
|
|
it unless `HSTS_MAX_AGE` is `0`. A browser keeps the rule until `max-age` runs
|
|
out, so serve webhooker on a hostname you will keep on HTTPS. The header always
|
|
carries `preload`. On a whole domain such as `example.com`, that lets the domain
|
|
be added to the HTTPS-only list that browsers ship with. That listing is hard to
|
|
undo.
|
|
|
|
## Recovering a lost admin password
|
|
|
|
`webhooker resetpw` sets an existing account's password from the command line.
|
|
The deployment must be stopped first: the command takes the same `DATA_DIR` lock
|
|
the server does and refuses while a running instance holds it.
|
|
|
|
```bash
|
|
DATA_DIR=/var/lib/webhooker webhooker resetpw -generate admin
|
|
```
|
|
|
|
`-generate` makes up a random password and prints it. Without `-generate`, the
|
|
new password is read from standard input. It must be at least 8 bytes long. It
|
|
is never taken as a command-line argument, because other accounts on the host
|
|
can read those. The command creates nothing: a missing `DATA_DIR`, a missing
|
|
`webhooker.db` or an unknown username is an error.
|
|
|
|
In a container, run the binary at `/app/webhooker` with the stopped container's
|
|
volume mounted:
|
|
|
|
```bash
|
|
docker run --rm -v /path/to/data:/var/lib/webhooker \
|
|
webhooker /app/webhooker resetpw -generate admin
|
|
```
|
|
|
|
## Backup and restore
|
|
|
|
All state lives in `DATA_DIR`. Back up the whole directory. It holds:
|
|
|
|
- the main database, `webhooker.db`: the session key (which signs the session
|
|
cookies), the `admin` account's password hash, and the webhooks, entrypoints
|
|
and targets;
|
|
- one event database per webhook, `events-{webhook_uuid}.db`: its events,
|
|
deliveries and attempts;
|
|
- the files of each archive target;
|
|
- the lock file, `webhooker.lock`, which is always empty.
|
|
|
|
Every database runs in WAL mode. WAL is SQLite's write-ahead log. Finished
|
|
writes land in a `-wal` file beside the database before they reach the `.db`
|
|
itself. A `-shm` file beside it indexes that log. A copy of the `.db` without
|
|
its `-wal` is missing data. A clean shutdown removes the `-wal` and `-shm`
|
|
files; a crash leaves them, and they must be carried with the `.db`. Keep
|
|
`DATA_DIR` on a local disk: WAL mode is not reliable on network filesystems, and
|
|
webhooker does not check for one.
|
|
|
|
A copy taken while the service runs can catch a `.db` and its `-wal` at two
|
|
different instants. Stop the service, copy the directory, and start it again.
|
|
Without downtime, copy each database with SQLite's own backup command instead,
|
|
which writes one complete file:
|
|
|
|
```bash
|
|
for db in /path/to/data/*.db; do
|
|
sqlite3 "$db" ".backup '/path/to/backup/$(basename "$db")'"
|
|
done
|
|
```
|
|
|
|
Each file is captured at its own instant. A webhook created during the backup
|
|
can be in the copy of `webhooker.db` with no event database in the backup. A
|
|
restore then creates that event database empty.
|
|
|
|
To restore, stop the service and put back every file together, with any `-wal`
|
|
and `-shm` files the backup holds. A missing or empty database is created again
|
|
when it is opened, and `created a new, empty database` is logged at `WARN` with
|
|
its path. For `webhooker.db` that means the main database was lost, most often
|
|
because the volume holding `DATA_DIR` is not mounted. For an event database it
|
|
means that one file was lost; the webhook comes back with its settings but no
|
|
history.
|
|
|
|
A backup holds every event's headers and body, and the targets' credentials in
|
|
plain text. Protect it accordingly.
|
|
|
|
## Security
|
|
|
|
### The entrypoint URL is the credential
|
|
|
|
Each entrypoint is a random UUID served at `/h/{uuid}`. Holding that URL is what
|
|
authorises a sender. The receiver checks nothing else: no shared secret, no HMAC
|
|
signature (a hash of the body made with a shared key), no token header. If a
|
|
sender adds signature headers anyway, they are stored and forwarded as ordinary
|
|
headers.
|
|
|
|
Keep the URL out of tickets, chat and screenshots. webhooker's own log never
|
|
writes it. The reverse proxy's access log does hold it, in the request path.
|
|
|
|
### The web interface
|
|
|
|
- Passwords are hashed with Argon2id, a slow password hashing function.
|
|
- Session cookies are signed, `HttpOnly` (hidden from page scripts) and
|
|
`SameSite=Lax` (not sent with cross-site form posts). They carry `Secure`
|
|
(sent only over HTTPS) when the request carries `X-Forwarded-Proto: https`.
|
|
- Every form is protected against cross-site request forgery (CSRF). CSRF is
|
|
when another site makes your browser submit a form here. Each form carries a
|
|
token tied to a cookie, and a submission's `Origin` must name this host.
|
|
- Form bodies are capped at 1 MB.
|
|
- Every response carries these headers:
|
|
- `Strict-Transport-Security`, the HSTS header, unless `HSTS_MAX_AGE` is
|
|
`0`;
|
|
- `X-Content-Type-Options: nosniff`, which stops the browser guessing a
|
|
file's type;
|
|
- `X-Frame-Options: DENY`, which stops other sites showing these pages in a
|
|
frame;
|
|
- `Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'`,
|
|
which tells the browser where scripts and styles may come from;
|
|
- `Referrer-Policy: strict-origin-when-cross-origin`, which tells another
|
|
site only which site a visitor came from, not which page;
|
|
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`, which
|
|
denies the pages the camera, microphone and location.
|
|
- `/metrics` sits behind HTTP basic authentication, a username and password sent
|
|
with each request.
|
|
|
|
### Sign-in limits
|
|
|
|
The password is checked first, and only a failed sign-in is counted. A correct
|
|
password is never answered `429`. Failures are counted per client and submitted
|
|
username. From the fifth failure within a minute, each failure is answered
|
|
`429`. At most two password checks run at once; a request that gets no turn
|
|
within 5 seconds is answered `503`. So guessing speed is bounded by the limit of
|
|
two checks at a time, not by the failure count. Choose a long random `admin`
|
|
password, or rate-limit `POST /pages/login` at the reverse proxy.
|
|
|
|
### Delivery destinations
|
|
|
|
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
|
|
(SSRF). webhooker refuses it with the SSRF guard.
|
|
|
|
Every target URL must use `http` or `https`. By default its hostname must also
|
|
point at a public address, one reachable from the internet. The SSRF guard
|
|
refuses these ranges:
|
|
|
|
- loopback, `127.0.0.0/8` and `::1`;
|
|
- the RFC 1918 private ranges, `10.0.0.0/8`, `172.16.0.0/12` and
|
|
`192.168.0.0/16`;
|
|
- link-local space, which is valid only on one network segment;
|
|
- carrier-grade NAT space, which internet providers use inside their own
|
|
networks;
|
|
- multicast, the addresses that reach a group of hosts at once;
|
|
- the IPv6 unique local range, `fc00::/7`, the private space of IPv6;
|
|
- the other reserved ranges.
|
|
|
|
The SSRF guard runs when a target is saved and again when each delivery
|
|
connects. So a hostname that changes its address later is still checked.
|
|
Redirects are followed up to ten hops, and each hop is checked. A hop to another
|
|
host or port, or from `https` to `http`, drops every configured or forwarded
|
|
header except `Content-Type`, which names the body's format. So a redirect
|
|
cannot pass a credential to another host.
|
|
|
|
Egress means the connections webhooker opens outward to deliver.
|
|
[`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network) lets deliveries
|
|
reach networks you name.
|
|
|
|
### What the logs contain
|
|
|
|
webhooker writes one access log record per request to standard output, as plain
|
|
text on a terminal and as JSON otherwise. The access log never records the query
|
|
string. On the receiver route it records the URL as `/h/{uuid}`, never the real
|
|
path, so entrypoint UUIDs stay out of the log.
|
|
|
|
The `admin` password is printed in the clear on the first boot and by
|
|
`webhooker resetpw -generate`. A `log` target writes the whole event to the log:
|
|
query string, headers and body.
|
|
|
|
When `SENTRY_DSN` is set, error reports go to Sentry, an error reporting
|
|
service. A report carries no query string, body or cookies. It carries the route
|
|
pattern in place of the path. A route pattern is the path with its variable part
|
|
left as a name, such as `/h/{uuid}`.
|
|
|
|
## Design
|
|
|
|
webhooker is one process. An HTTP server receives requests, a delivery engine
|
|
sends them on in the background, and SQLite holds everything in between.
|
|
|
|
The receiver stores the event and one pending delivery per active target in one
|
|
database write, then queues the deliveries. Background workers run them and
|
|
record each attempt. At start, a recovery routine queues again every delivery
|
|
left `pending` or `retrying`. It then runs every minute. Each run sends again a
|
|
`retrying` delivery the engine is not working on, and a `pending` one that has
|
|
sat untouched for 15 minutes. So a stop or a crash loses no delivery.
|
|
|
|
The main database holds configuration. Each webhook has its own event database,
|
|
so writes for one webhook never wait on another webhook's database. The program
|
|
starts in `cmd/webhooker/main.go`, and its logic is under `internal/`. The
|
|
`static` and `templates` packages embed the pages' HTML, CSS and JavaScript in
|
|
the binary.
|
|
|
|
## Configuration reference
|
|
|
|
All configuration comes from environment variables. Variables can also go in a
|
|
`.env` file in the working directory. A variable set in the real environment
|
|
wins over the file. A `.env` file that cannot be parsed aborts startup.
|
|
|
|
A variable that is unset or empty takes its default. A variable that is set but
|
|
cannot be parsed aborts startup with a message naming it. A Go duration is a
|
|
number with a unit, such as `30s`, `15m` or `24h`.
|
|
|
|
### `WEBHOOKER_ENVIRONMENT`
|
|
|
|
`dev` or `prod`. Default `prod`. This controls one thing: CORS, the headers that
|
|
let a page on another site call this one from a browser. An origin is a site as
|
|
a browser names it: scheme, host and port. In `dev`, every origin is allowed. In
|
|
`prod`, no CORS headers are sent.
|
|
|
|
### `PORT`
|
|
|
|
The port webhooker listens on. Default `8080`. Docker marks the image's
|
|
container healthy by a check every 30 seconds. The check requests port `8080`
|
|
whatever `PORT` says, so a container started with another `PORT` reports
|
|
unhealthy.
|
|
|
|
### `BIND_ADDRESS`
|
|
|
|
The IP address webhooker listens on. Default `127.0.0.1`. The Docker image sets
|
|
`0.0.0.0`. It must be an IP address literal: `localhost` and a value with a port
|
|
are refused. `0.0.0.0` listens on IPv4 only; `::` listens on IPv6 and IPv4.
|
|
|
|
### `DATA_DIR`
|
|
|
|
The directory for every SQLite database and the lock file. Default
|
|
`/var/lib/webhooker`. It is created if it is missing.
|
|
|
|
Exactly one webhooker process may use a `DATA_DIR` at a time. Two would both run
|
|
the recovery routine and could send one delivery twice. At startup the process
|
|
locks `{DATA_DIR}/webhooker.lock` with `flock`, the kernel's file lock. A second
|
|
process pointed at the same directory exits with a message that names the
|
|
directory and the lock file.
|
|
|
|
### `DEBUG`
|
|
|
|
`true` or `false`. Default `false`. It accepts the spellings Go's
|
|
`strconv.ParseBool` accepts; `yes` and `on` are refused. `DEBUG=true` lowers the
|
|
log level to `DEBUG`, which adds SQL statements, with `?` in place of each
|
|
value, and other diagnostic lines.
|
|
|
|
### `METRICS_USERNAME` and `METRICS_PASSWORD`
|
|
|
|
The basic authentication credentials for `/metrics`. Default empty. Set both or
|
|
neither. With both set, `/metrics` is served behind basic authentication. With
|
|
neither set, the route does not exist. With only one set, startup fails.
|
|
|
|
### `SENTRY_DSN`
|
|
|
|
The DSN for Sentry error reporting: the URL that tells the Sentry library where
|
|
to send reports. Default empty, which leaves reporting off. A value the Sentry
|
|
library cannot parse aborts startup.
|
|
|
|
### `RETENTION_SWEEP_INTERVAL`
|
|
|
|
How often two background jobs run. The retention sweep deletes events older than
|
|
each webhook's retention period, with their deliveries. The archive sweep
|
|
deletes archive rows older than their archive target's expiry. A Go duration
|
|
greater than zero. Default `1h`.
|
|
|
|
### `SESSION_IDLE_TIMEOUT`
|
|
|
|
How long a session may go unused before it ends. A Go duration. Default `24h`. A
|
|
zero or negative value disables idle expiry. The 7 day absolute limit always
|
|
applies.
|
|
|
|
### `HSTS_MAX_AGE`
|
|
|
|
The `max-age` of the `Strict-Transport-Security` header, as a Go duration in
|
|
whole seconds. Default `87600h` (ten years). `0` sends no header. Read
|
|
[HSTS](#hsts) before changing it.
|
|
|
|
### `RECEIVER_RATE_LIMIT`
|
|
|
|
How many requests per minute one client may send to one entrypoint. Default
|
|
`120`. A client over the limit is answered `429`. A second limit of ten times
|
|
this value covers the whole `/h/` route per client.
|
|
|
|
### `TRUSTED_PROXIES`
|
|
|
|
A comma-separated list of networks in CIDR notation, which names a network as an
|
|
address and a prefix length, such as `10.0.0.0/8`. A bare address counts as a
|
|
single host. Default `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.
|
|
It is read only when the connecting machine is inside the list. webhooker never
|
|
reads `X-Real-IP` or `True-Client-IP`, two other headers with the same job. Some
|
|
clients may reach webhooker, or the proxy in front of it, from a private
|
|
address. If so, set the list to the proxy's address alone. Otherwise those
|
|
clients can choose their own `X-Forwarded-For` and bypass every rate limit.
|
|
|
|
### Allowing egress to your own network
|
|
|
|
`ALLOWED_EGRESS_CIDRS` is a comma-separated list of networks in CIDR notation
|
|
that delivery targets may reach although the SSRF guard refuses them by default.
|
|
Default empty. Example: `10.0.0.0/8, 172.17.0.0/16`. When the list is set,
|
|
startup logs it at `WARN`.
|
|
|
|
Anyone with the `admin` account can make webhooker send requests to every
|
|
network you list, and read the responses in **Full Event Log**. List the
|
|
smallest networks that cover your destinations, and never `0.0.0.0/0` or `::/0`.
|
|
|
|
These addresses stay blocked whatever you list. They are link-local space, the
|
|
unspecified addresses and cloud metadata endpoints. The unspecified addresses
|
|
are `0.0.0.0` and `::`, which name no host. A cloud metadata endpoint is the
|
|
address a cloud gives each virtual machine for its own credentials. The blocked
|
|
addresses are:
|
|
|
|
- `169.254.0.0/16` and `fe80::/10`;
|
|
- `fd00:ec2::254/128` and `fd00:ec2::23/128`;
|
|
- `fd20:ce::254/128`;
|
|
- `fd00:c1::a9fe:a9fe/128`;
|
|
- `fd00:42::42/128`;
|
|
- `fd00:a9fe:a9fe::1/128`;
|
|
- `100.100.100.200/32`;
|
|
- `192.0.0.192/32`;
|
|
- `0.0.0.0/32` and `::/128`;
|
|
- `::a9fe:a9fe/128` and `64:ff9b::a9fe:a9fe/128`.
|
|
|
|
Some clouds' metadata addresses are not on this list. If yours is not here, do
|
|
not list the network that contains it. Azure's public metadata address,
|
|
`168.63.129.16`, is refused by default but is reopened by a list that covers it.
|
|
|
|
## Delivery reference
|
|
|
|
An `http` target posts the event body to its URL. The request carries the
|
|
event's `Content-Type` and headers. The target's configured **Headers** are
|
|
added next and replace event headers of the same name. Every `http` and `slack`
|
|
request carries `User-Agent: webhooker/{version}`. `User-Agent` is the header
|
|
that names the sending program. The **Headers** field refuses `User-Agent`. A
|
|
response with a status from `200` to `299` is a success. The first 4 KiB of the
|
|
response body is stored with the attempt. **Timeout** is at most 300 seconds,
|
|
and blank means the 30 second default. **Pass the query string on to this
|
|
target** appends the event's query string to the URL.
|
|
|
|
A `slack` target posts a message that describes the event and shows its body in
|
|
a code block, cut at 3500 bytes. An archive target appends the event as a row to
|
|
its own SQLite file. A `log` target writes the event as one `INFO` line.
|
|
|
|
**Delivery attempts** is from 1 to 20. A failed attempt marks the delivery
|
|
`retrying` and schedules the next attempt; the wait doubles each time, starting
|
|
at one second. After the last allowed attempt fails, the delivery is marked
|
|
`failed`.
|
|
|
|
A target with more than one delivery attempt has a circuit breaker, which stops
|
|
hammering a destination that is down. After 5 failures in a row, deliveries to
|
|
the target pause for 30 seconds. Then one delivery is sent as a probe; the
|
|
others wait until a probe succeeds. The webhook page says when a target is
|
|
paused.
|
|
|
|
### Archives
|
|
|
|
An archive target writes to
|
|
`archive-{webhook_name}-{target_name}-{target_uuid}.db` in `DATA_DIR`, the two
|
|
names made safe for a file name. A rotation of `monthly`, `daily` or `hourly`
|
|
starts a new file for each period. Periods follow UTC, the universal time that
|
|
ignores local time zones. The period goes before `.db`, such as
|
|
`-2026-10-01.db`. Rows older than the expiry are deleted each time a file is
|
|
opened and by the archive sweep. Renaming the webhook or the target renames the
|
|
files. **Download** on the webhook page returns the target's whole archive as
|
|
one JSON file compressed with gzip.
|
|
|
|
## HTTP routes reference
|
|
|
|
- `GET /.well-known/healthcheck` returns JSON with the version and the uptime.
|
|
- `POST /h/{uuid}` is the receiver. Its answers:
|
|
- any method but `POST` gets `405`;
|
|
- a client over a rate limit gets `429`;
|
|
- an unknown UUID gets `404`;
|
|
- a deactivated entrypoint gets `410`;
|
|
- a body over 1 MB gets `413`;
|
|
- a body that cannot be read in full gets `400`, for example one still
|
|
arriving after the server's 10 second read limit;
|
|
- a failure to store the event gets `500`;
|
|
- a stored event gets `200` with `{"status":"ok"}`.
|
|
- `GET /pages/login` shows the sign-in form, `POST /pages/login` signs in, and
|
|
`POST /pages/logout` signs out.
|
|
- The pages behind the sign-in live under `/user/`, `/settings`, `/hooks` and
|
|
`/hook/`. A `GET` to one of them without a session is redirected to
|
|
`/pages/login`.
|
|
- `GET /metrics` exists only when `METRICS_USERNAME` and `METRICS_PASSWORD` are
|
|
set.
|
|
|
|
## Metrics reference
|
|
|
|
`/metrics` serves these metrics for Prometheus, the monitoring system, plus the
|
|
Go runtime and process metrics the Prometheus library provides. A label is a
|
|
name and value attached to a metric. Each delivery metric carries one label,
|
|
`target_type`, which is `http`, `slack`, `database`, `log` or `unknown`.
|
|
|
|
- `webhooker_events_received_total` and `webhooker_events_resubmitted_total`,
|
|
two counters (totals that only grow) with no label;
|
|
- `webhooker_delivery_attempts_total`;
|
|
- `webhooker_deliveries_succeeded_total` and
|
|
`webhooker_deliveries_failed_total`;
|
|
- `webhooker_delivery_retries_total` and `webhooker_delivery_replays_total`;
|
|
- `webhooker_delivery_duration_seconds`, a histogram (a distribution) of how
|
|
long one attempt took;
|
|
- `webhooker_deliveries_pending` and `webhooker_deliveries_retrying`, gauges
|
|
(current values) sampled every 30 seconds;
|
|
- `webhooker_circuit_breakers_open`.
|
|
|
|
`http_request_duration_seconds` and `http_response_size_bytes` measure inbound
|
|
requests. Their `handler` label is the route pattern, never the real path.
|
|
`http_requests_inflight` counts all routes together.
|
|
|
|
## Development
|
|
|
|
You need the Go version named in `go.mod`, and Docker. The tests, the linters,
|
|
`prettier` and the stylesheet build run in Docker. Their versions are pinned
|
|
(fixed to one exact release) in the `Dockerfile`, `package.json` and
|
|
`yarn.lock`. `gofmt` runs on the host. `goimports` runs on the host too, when it
|
|
is installed. `script/bootstrap` does not install it.
|
|
|
|
```bash
|
|
make bootstrap # Install git, make, Go and the Go modules
|
|
make dev # go run ./cmd/webhooker
|
|
make check # test, lint, fmt-check and css-check
|
|
make test # Run the tests in Docker
|
|
make lint # Run gofmt, golangci-lint and ESLint in Docker
|
|
make fmt # Format Go and Markdown
|
|
make build # Build bin/webhooker, version-stamped
|
|
make docker # Build the Docker image
|
|
```
|
|
|
|
Most `make` targets run a script in `script/`, listed under
|
|
[Entrypoints](#entrypoints). `build`, `run`, `dev`, `deps` and `clean` run their
|
|
own commands. Every `docker build` in `script/` passes `--no-cache`, so a check
|
|
that passes has really run.
|
|
|
|
The binary's version comes from `script/version`: `git describe` on the
|
|
checkout, or `VERSION` when that is set. It is shown at
|
|
`/.well-known/healthcheck` and in the page footer.
|
|
|
|
The pages use one third-party script, Alpine.js. They load `@alpinejs/csp`, the
|
|
version of it that works when the Content-Security-Policy forbids running text
|
|
as code. The npm registry is where JavaScript packages are published. The
|
|
package file in `3p/` is byte for byte the one the npm registry publishes.
|
|
|
|
A headless browser is a browser without a window. A browser test in
|
|
`internal/server` loads the pages in one under the real Content-Security-Policy.
|
|
`make test` leaves it out; run `make test-browser` after changing `templates/`
|
|
or `static/js/`. `static/css/tailwind.css` is generated by Tailwind, a CSS
|
|
generator. The file is committed. `make check` fails when it is out of date, and
|
|
`make css` regenerates it.
|
|
|
|
## Entrypoints
|
|
|
|
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).
|
|
|
|
- `script/bootstrap` installs git, make, Go and the Go modules.
|
|
- `script/setup` runs `script/bootstrap`, then `script/install-precommit`.
|
|
- `script/projectname` prints `webhooker`.
|
|
- `script/assets` extracts Alpine.js from `3p/` to `static/js/alpine.min.js`.
|
|
- `script/test` runs the tests in Docker.
|
|
- `script/test-browser` runs the browser test in Docker.
|
|
- `script/lint` runs `gofmt`, `golangci-lint` and ESLint in Docker.
|
|
- `script/fmt` formats the Go code with `gofmt` on the host and the Markdown
|
|
with `prettier` in Docker. It also runs `goimports` when that is installed.
|
|
- `script/fmt-check` fails when `gofmt` or `prettier` would change a file. It
|
|
does not run `goimports`, and it writes nothing.
|
|
- `script/css` regenerates `static/css/tailwind.css` in Docker.
|
|
- `script/css-check` fails when `static/css/tailwind.css` is out of date.
|
|
- `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`.
|
|
- CI is the check that runs on every push. It runs `script/cibuild`:
|
|
`script/bootstrap`, `script/check`, then the image build.
|
|
- `script/precommit` checks that `go mod tidy` changes nothing, then runs
|
|
`script/check`.
|
|
- `script/install-precommit` installs the git hook that runs `script/precommit`
|
|
before each commit.
|
|
|
|
## TODO
|
|
|
|
See [`TODO.md`](TODO.md).
|
|
|
|
## License
|
|
|
|
MIT.
|
|
|
|
## Author
|
|
|
|
[@sneak](https://sneak.berlin)
|