1 Commits
Author SHA1 Message Date
sneak a69ec23979 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 10:00:32 +00:00
+49 -60
View File
@@ -39,11 +39,11 @@ Five words name the moving parts.
The part of webhooker that answers requests to `/h/{uuid}` is the receiver. It 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`. 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 Delivery is at least once, not exactly once. If a delivery reaches the target
more than once. If a delivery reaches the target but webhooker cannot record but webhooker cannot record that outcome, it sends the delivery again later. A
that outcome, it sends the delivery again later. A delivery in flight when the delivery in flight when the service stops is also sent again at the next start.
service stops is also sent again at the next start. webhooker adds no delivery webhooker adds no delivery identifier of its own to the request, so make your
identifier of its own to the request, so make your targets tolerate a duplicate. targets tolerate a duplicate.
## Getting Started ## Getting Started
@@ -57,9 +57,7 @@ DATA_DIR=../webhooker-data make dev
``` ```
`DATA_DIR` is where webhooker keeps its databases. It defaults to `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 `/var/lib/webhooker`, so point it at a directory you can write to.
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 The first start creates the `admin` account and prints its password once, in a
ruled box on standard output: ruled box on standard output:
@@ -140,8 +138,6 @@ Under **Targets**, **Add** asks for the type first, then that type's settings.
`http` and `slack` targets also take **Delivery attempts**: how many times each `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 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. 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 ### Events
@@ -151,11 +147,11 @@ 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 and every attempt. **Failed** and **Pending** narrow it to events with a failed
delivery, or with one still pending or retrying. delivery, or with one still pending or retrying.
A finished delivery has a **Replay** button while its target exists. **Replay** **Replay** sits beside each finished delivery. It creates a new delivery of the
creates a new delivery of the same event to the same target, with the target's same event to the same target, with the target's settings as they stand now.
settings as they stand now. **Resubmit** sits beside each event. It stores a **Resubmit** sits beside each event. It stores a copy of the event as a new
copy of the event as a new event and delivers it to every target that is active event and delivers it to every target that is active now. Both actions are
now. Both actions are limited to 30 per minute per client. limited to 30 per minute per client.
### Profile and settings ### Profile and settings
@@ -181,8 +177,7 @@ its published port.
The app runs as user `webhooker`. The image's start-up script is 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 `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 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 creates every SQLite file with mode `0600`.
`0600`).
### Running under upaas ### Running under upaas
@@ -197,10 +192,9 @@ Read it with `docker logs` and the name upaas gave the container.
webhooker speaks plain HTTP. It does not handle TLS, the encryption behind webhooker speaks plain HTTP. It does not handle TLS, the encryption behind
HTTPS: it holds no certificate and accepts no HTTPS connections. The reverse 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 proxy holds the certificate and reaches webhooker over a private address. On a
host is a host without Docker. On a bare host that is loopback, the address only bare host that is loopback, the address only this host can reach. For the image
this host can reach. For the image it is the Docker bridge, the private network it is the Docker bridge, the private network Docker puts containers on.
Docker puts containers on.
Four things must be right. Four things must be right.
@@ -270,11 +264,10 @@ the server does and refuses while a running instance holds it.
DATA_DIR=/var/lib/webhooker webhooker resetpw -generate admin DATA_DIR=/var/lib/webhooker webhooker resetpw -generate admin
``` ```
`-generate` makes up a random password and prints it. Without `-generate`, the Without `-generate`, the new password is read from standard input. It must be at
new password is read from standard input. It must be at least 8 bytes long. It least 8 characters. It is never taken as a command-line argument, because other
is never taken as a command-line argument, because other accounts on the host accounts on the host can read those. The command creates nothing: a missing
can read those. The command creates nothing: a missing `DATA_DIR`, a missing `DATA_DIR`, a missing `webhooker.db` or an unknown username is an error.
`webhooker.db` or an unknown username is an error.
In a container, run the binary at `/app/webhooker` with the stopped container's In a container, run the binary at `/app/webhooker` with the stopped container's
volume mounted: volume mounted:
@@ -288,9 +281,8 @@ docker run --rm -v /path/to/data:/var/lib/webhooker \
All state lives in `DATA_DIR`. Back up the whole directory. It holds: 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 - the main database, `webhooker.db`: the session key, the `admin` account's
cookies), the `admin` account's password hash, and the webhooks, entrypoints password hash, and the webhooks, entrypoints and targets;
and targets;
- one event database per webhook, `events-{webhook_uuid}.db`: its events, - one event database per webhook, `events-{webhook_uuid}.db`: its events,
deliveries and attempts; deliveries and attempts;
- the files of each archive target; - the files of each archive target;
@@ -360,8 +352,9 @@ writes it. The reverse proxy's access log does hold it, in the request path.
file's type; file's type;
- `X-Frame-Options: DENY`, which stops other sites showing these pages in a - `X-Frame-Options: DENY`, which stops other sites showing these pages in a
frame; frame;
- `Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'`, - a Content-Security-Policy (CSP), the header that tells the browser where
which tells the browser where scripts and styles may come from; scripts and styles may come from, set to
`default-src 'self'; script-src 'self'; style-src 'self'`;
- `Referrer-Policy: strict-origin-when-cross-origin`, which tells another - `Referrer-Policy: strict-origin-when-cross-origin`, which tells another
site only which site a visitor came from, not which page; site only which site a visitor came from, not which page;
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`, which - `Permissions-Policy: camera=(), microphone=(), geolocation=()`, which
@@ -376,7 +369,7 @@ password is never answered `429`. Failures are counted per client and submitted
username. From the fifth failure within a minute, each failure is answered 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 `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 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` 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. password, or rate-limit `POST /pages/login` at the reverse proxy.
### Delivery destinations ### Delivery destinations
@@ -457,20 +450,18 @@ number with a unit, such as `30s`, `15m` or `24h`.
### `WEBHOOKER_ENVIRONMENT` ### `WEBHOOKER_ENVIRONMENT`
`dev` or `prod`. Default `prod`. This controls one thing: CORS, the headers that `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 let a page on another site call this one from a browser. In `dev`, every origin
a browser names it: scheme, host and port. In `dev`, every origin is allowed. In is allowed. In `prod`, no CORS headers are sent.
`prod`, no CORS headers are sent.
### `PORT` ### `PORT`
The port webhooker listens on. Default `8080`. Docker marks the image's The port the listener binds. Default `8080`. Docker marks the image's container
container healthy by a check every 30 seconds. The check requests port `8080` healthy by a check every 30 seconds. The check requests port `8080` whatever
whatever `PORT` says, so a container started with another `PORT` reports `PORT` says, so a container started with another `PORT` reports unhealthy.
unhealthy.
### `BIND_ADDRESS` ### `BIND_ADDRESS`
The IP address webhooker listens on. Default `127.0.0.1`. The Docker image sets The IP address the listener binds. 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 `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. are refused. `0.0.0.0` listens on IPv4 only; `::` listens on IPv6 and IPv4.
@@ -489,7 +480,7 @@ directory and the lock file.
`true` or `false`. Default `false`. It accepts the spellings Go's `true` or `false`. Default `false`. It accepts the spellings Go's
`strconv.ParseBool` accepts; `yes` and `on` are refused. `DEBUG=true` lowers the `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 log level to `DEBUG`, which adds every SQL statement, with `?` in place of each
value, and other diagnostic lines. value, and other diagnostic lines.
### `METRICS_USERNAME` and `METRICS_PASSWORD` ### `METRICS_USERNAME` and `METRICS_PASSWORD`
@@ -539,10 +530,10 @@ private ranges. A set value replaces the default.
The list decides whose `X-Forwarded-For` header is believed. `X-Forwarded-For` 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. 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 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 reads `X-Real-IP` or `True-Client-IP`, two other headers with the same job. If
clients may reach webhooker, or the proxy in front of it, from a private any client can 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 address, set the list to the proxy's address alone. Otherwise those clients can
clients can choose their own `X-Forwarded-For` and bypass every rate limit. choose their own `X-Forwarded-For` and bypass every rate limit.
### Allowing egress to your own network ### Allowing egress to your own network
@@ -551,9 +542,9 @@ 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, Default empty. Example: `10.0.0.0/8, 172.17.0.0/16`. When the list is set,
startup logs it at `WARN`. startup logs it at `WARN`.
Anyone with the `admin` account can make webhooker send requests to every Anyone with the admin account can make webhooker send requests to every network
network you list, and read the responses in **Full Event Log**. List the you list, and read the responses in **Full Event Log**. List the smallest blocks
smallest networks that cover your destinations, and never `0.0.0.0/0` or `::/0`. 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 These addresses stay blocked whatever you list. They are link-local space, the
unspecified addresses and cloud metadata endpoints. The unspecified addresses unspecified addresses and cloud metadata endpoints. The unspecified addresses
@@ -572,9 +563,9 @@ addresses are:
- `0.0.0.0/32` and `::/128`; - `0.0.0.0/32` and `::/128`;
- `::a9fe:a9fe/128` and `64:ff9b::a9fe:a9fe/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 This list is not every cloud's metadata address. If yours is not here, do not
not list the network that contains it. Azure's public metadata address, list the block that contains it. Azure's public `168.63.129.16` is refused by
`168.63.129.16`, is refused by default but is reopened by a list that covers it. default but is reopened by a list that covers it.
## Delivery reference ## Delivery reference
@@ -583,7 +574,7 @@ 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` 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 request carries `User-Agent: webhooker/{version}`. `User-Agent` is the header
that names the sending program. The **Headers** field refuses `User-Agent`. A 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 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, 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 and blank means the 30 second default. **Pass the query string on to this
target** appends the event's query string to the URL. target** appends the event's query string to the URL.
@@ -624,8 +615,6 @@ one JSON file compressed with gzip.
- an unknown UUID gets `404`; - an unknown UUID gets `404`;
- a deactivated entrypoint gets `410`; - a deactivated entrypoint gets `410`;
- a body over 1 MB gets `413`; - 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 failure to store the event gets `500`;
- a stored event gets `200` with `{"status":"ok"}`. - a stored event gets `200` with `{"status":"ok"}`.
- `GET /pages/login` shows the sign-in form, `POST /pages/login` signs in, and - `GET /pages/login` shows the sign-in form, `POST /pages/login` signs in, and
@@ -692,12 +681,12 @@ 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 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. 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 A browser test in `internal/server` loads the pages in a headless browser, one
`internal/server` loads the pages in one under the real Content-Security-Policy. without a window, under the real Content-Security-Policy. `make test` leaves it
`make test` leaves it out; run `make test-browser` after changing `templates/` out; run `make test-browser` after changing `templates/` or `static/js/`.
or `static/js/`. `static/css/tailwind.css` is generated by Tailwind, a CSS `static/css/tailwind.css` is generated by Tailwind, a CSS generator, and
generator. The file is committed. `make check` fails when it is out of date, and committed; `make check` fails when it is out of date, and `make css` regenerates
`make css` regenerates it. it.
## Entrypoints ## Entrypoints