Rewritten from scratch against the code on next. The old text was the list of what a reader needs covered. Model: fable-5-1
webhooker
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.
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. You can read every event and every delivery attempt in the web interface, send a stored event again, and watch the whole thing through Prometheus metrics.
webhooker is written in Go by @sneak. The license is MIT.
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 random, 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 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.
A request to an entrypoint is stored as an event. In the same database
transaction, one pending delivery is created for each active target of the
webhook. The sender gets 200 once that transaction has committed. Workers then
carry out the deliveries. A delivery to an HTTP or Slack target that fails is
retried, up to the number of attempts the target allows.
Delivery is at least once, not exactly once. If a send reaches the target but the record of that outcome cannot be written, the delivery is sent again later. A send that is 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 receiver tolerate a duplicate.
Quick start
You need the Go version named in go.mod, and Docker for the checks.
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 in every environment, so point it at a directory you can
write to. Keep it outside the clone: the databases hold the session key.
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 the published image instead of a clone:
docker run -d \
-p 127.0.0.1:8080:8080 \
-v /path/to/data:/var/lib/webhooker \
webhooker:latest
Read Running with Docker before publishing the port
anywhere but 127.0.0.1.
Using the web interface
Signing in
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.
If a page asked for a sign-in, signing in returns you to that page.
Creating a webhook
The webhook list is at /hooks. New Webhook asks for a name, an optional
description and a retention period in days. Events older than the retention
period are deleted, along with their deliveries. The default is 30 days. 0
keeps events forever.
The form can also create the first targets. An HTTP target URL creates an http
target named HTTP. The Archive checkbox creates an archive target named
Archive, with the expiry and rotation chosen beside it.
Every new webhook gets one entrypoint, described as Default entrypoint.
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 like a password. Read The entrypoint URL is the credential.
Add creates another entrypoint with its own URL. Several entrypoints on one webhook let you tell senders apart while delivering to the same targets.
Edit changes an entrypoint's description. Its URL never changes. To retire a URL, deactivate the entrypoint or delete it, then create a new one.
A deactivated entrypoint answers 410 Gone. A deleted or unknown one answers
404.
Beside each entrypoint the page shows when its last event arrived, and how many events arrived on it within the retention period.
Targets
Add under Targets asks for the type first, then that type's settings.
- An
httptarget 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
slacktarget 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
type is
databasein the data. It takes an expiry and a rotation. - A
logtarget writes each event to the application log. It has no settings beyond its name.
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.
The target list shows each target's settings. A destination URL is shown as its
scheme and host, with /... standing for its path. Headers are shown as a
count. The target edit page is the one place that shows a URL and its headers in
full.
The list also shows, for each target, how many deliveries have ever succeeded and failed, and how many did so in the last 24 hours. For an archive target it names the current archive file, the size of all its files together, and when the last write happened.
Deactivate stops new events reaching the target. Deliveries already created are unaffected. Delete removes the target. An archive target's files stay on disk.
A target's type cannot be changed. Create a new target to deliver another way.
Statistics
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. 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.
Recent events and the event log
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 to the stored bytes.
Full Event Log at /hook/{id}/events shows the 50 newest events with their
deliveries and every attempt. Failed narrows it to events with a failed
delivery. Pending narrows it to events with a delivery still pending or
retrying. Each attempt shows its time, status code, duration, error, and the
first 4 KiB of the response.
Each event has a page of its own at /hook/{id}/events/{eventID}. It shows the
whole request: query string, headers, body and every delivery.
Replay and resubmit
Replay sits beside each finished delivery. It creates a new delivery of the same event to the same target and sends it on the ordinary path. The target's settings as they stand now are used, so a replay is how you resend after fixing the destination. The original delivery is left as it was. The new one is marked as a replay.
A replay is refused in three cases:
- the delivery has not finished;
- its target is deactivated or deleted;
- another delivery of that event to that target is already in flight.
A delivery whose target was deleted shows no Replay button.
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. That includes targets created after the original arrived. The copy records which event it came from, and the log shows the link both ways. Resubmitting the same event many times is allowed.
Both actions are limited to 30 per minute per client.
Profile and settings
/user/{username} shows your account and lets you change your password. The
current password is checked first. The new password must not be empty and must
match its confirmation. Password changes are limited to 5 per minute per client.
/settings lists every configuration variable with the value the running server
loaded. It is read only. METRICS_PASSWORD and SENTRY_DSN show only as set or
not set.
Deploying
Running with Docker
docker run -d \
-p 127.0.0.1:8080:8080 \
-v /path/to/data:/var/lib/webhooker \
webhooker:latest
The image listens on every address inside the container: it ships
BIND_ADDRESS=0.0.0.0. The container's network namespace is the boundary, and
the -p flag decides what the host exposes. Publish to 127.0.0.1:8080, the
address your reverse proxy connects from, and nothing wider. A bare
-p 8080:8080 opens the port on every interface of the host, and Docker's
forwarding rules sit ahead of most host firewalls.
Do not set BIND_ADDRESS to an empty value in a container. An empty value means
unset, and unset falls back to the binary's default of 127.0.0.1. The
container then answers nothing on its published port and its health check fails.
A templated Compose file with an empty value is the usual way this happens.
A container on an IPv6-only Docker network needs BIND_ADDRESS=::.
The app runs as user webhooker (UID 1000). The image's entrypoint script
starts as root. It creates DATA_DIR if it is missing, gives it and its
contents to webhooker, and sets the directory to mode 0750. Then it runs the
app as webhooker. So a host directory can be mounted as it is, whoever owns
it. With --user, the script changes nothing and runs the app as that user.
webhooker creates every SQLite file with mode 0600, and resets a file found
with another mode when it opens it. webhooker.db holds target settings in
plain text, including bearer tokens and Slack URLs, so the file mode is what
protects them.
The image's health check requests /.well-known/healthcheck on the container's
own address every 30 seconds.
The same binary runs the password reset. The image's CMD is /app/webhooker,
and a command given to docker run replaces all of it:
docker run --rm -v webhooker-data:/var/lib/webhooker \
webhooker /app/webhooker resetpw -generate admin
Running under upaas
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
network. The proxy then reaches the app at
upaas-followed by the app name, on port8080. - Leave
PORT,BIND_ADDRESSandDATA_DIRunset. The image's health check probes port8080. - Mount one host directory at
/var/lib/webhooker. - Set
TRUSTED_PROXIESto your reverse proxy's address if clients can reach the proxy from a private address. See Trusted proxies.
The first start prints the admin password to the container's log. For an app
named webhooker:
docker logs upaas-webhooker
If the password is lost, stop the container, reset the password with the app's own image and volume, and start it again:
docker stop upaas-webhooker
docker run --rm --volumes-from upaas-webhooker \
"$(docker inspect -f '{{.Image}}' upaas-webhooker)" \
/app/webhooker resetpw -generate admin
docker start upaas-webhooker
Behind a reverse proxy
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 settings must be right. Each one fails silently when wrong.
- Keep the app port private. The binary binds
127.0.0.1by default. The image binds every address inside its container, so publish its port to127.0.0.1only. The plain port serves the sign-in form and the receiver without TLS. - Send
X-Forwarded-Proto: https. This header tells webhooker the request arrived over TLS. It decides theSecureflag on cookies and the strict origin check on forms. Without it, cookies are sent withoutSecure. webhooker reads this header from any peer, so the proxy must overwrite whatever the client sent. - Cover the proxy in
TRUSTED_PROXIES. Otherwise every client shares one rate-limit bucket keyed on the proxy's address. The default list is the private ranges, which does not cover a proxy that connects over loopback. - Send
Hostas$http_host, not$host.$hostdrops the port. The form origin check compares against the host it was given. On any port other than 443 every form submission then fails with403. - Keep the proxy's access log. It records the client address whatever
TRUSTED_PROXIESsays. It also records the full request path, which holds the entrypoint UUID of every event. Protect that log as you would the entrypoint URLs.
WEBHOOKER_ENVIRONMENT decides none of this. It controls CORS only. Leave it
unset in production.
nginx
A complete server block. Replace the server_name and the certificate paths.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on; # nginx 1.25.1+; older: listen 443 ssl http2;
server_name webhooker.example.com;
ssl_certificate /etc/ssl/certs/webhooker.example.com.crt;
ssl_certificate_key /etc/ssl/private/webhooker.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
# webhooker caps form and receiver bodies at 1 MB.
client_max_body_size 1m;
# The request path holds the entrypoint UUID. Protect this
# file like the entrypoint URL itself.
access_log /var/log/nginx/webhooker.access.log combined;
location / {
# A literal address, not localhost: localhost may resolve
# to ::1 first, where webhooker is not listening.
proxy_pass http://127.0.0.1:8080;
# $http_host, not $host: $host drops the port.
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Above webhooker's own 60 second request timeout, so its
# 503 reaches the client instead of nginx's 504.
proxy_read_timeout 70s;
}
}
server {
listen 80;
listen [::]:80;
server_name webhooker.example.com;
return 308 https://$host$request_uri;
}
With that block, webhooker's environment is:
BIND_ADDRESS=127.0.0.1 # the default
TRUSTED_PROXIES=127.0.0.1
If nginx runs on another host, BIND_ADDRESS becomes the address nginx connects
to, and TRUSTED_PROXIES becomes nginx's address. Firewall the port to that
address: the traffic between them is plain text.
HSTS
HSTS is a response header that tells browsers to use HTTPS only for this
hostname. Every response carries it unless HSTS_MAX_AGE is 0. With the
default of ten years it reads:
Strict-Transport-Security: max-age=315360000; includeSubDomains; preload
A browser that receives this header over HTTPS refuses plain HTTP to the
hostname until max-age runs out. Browsers ignore the header over plain HTTP.
So serve webhooker on a hostname you will keep on HTTPS.
includeSubDomains extends the rule to every name under the hostname. preload
invites browsers to put the domain on their built-in HSTS lists. The header
alone lists nothing. A listing happens when someone submits a registered domain
at hstspreload.org, and removal takes months.
Setting HSTS_MAX_AGE=0 stops the header. Browsers that already received it
keep the rule until their max-age runs out. To end the rule sooner, send a
short max-age such as 1s until returning browsers have picked it up.
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.
# Generate a new password and print it.
DATA_DIR=/var/lib/webhooker webhooker resetpw -generate admin
# Or supply one on standard input (at least 8 characters).
printf '%s' "$NEW_PASSWORD" | \
DATA_DIR=/var/lib/webhooker webhooker resetpw admin
The password is never taken as a command-line argument, because on Linux every account on the host can read a running process's arguments. Standard input is echoed when it is a terminal, and the prompt says so.
The command creates nothing. A DATA_DIR that does not exist, or that holds no
webhooker.db, is an error. An unknown username is an error. The new password
takes effect at the next sign-in. Sessions already signed in are unaffected.
webhooker help lists the subcommands.
Backup and restore
What to back up
All state lives in DATA_DIR. Back up the whole directory. It holds:
webhooker.db, one per install. Settings, the session key, users, webhooks, entrypoints and targets.events-{webhook_uuid}.db, one per webhook. Events, deliveries and delivery attempts.archive-{webhook_name}-{target_name}-{target_uuid}.db, one per archive target, or one per period for a target that rotates. The period goes before.db.webhooker.lock, which is always empty and holds no state.
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 {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.
A crash leaves them, and they must be carried with the .db.
DATA_DIR must be on a local filesystem. SQLite maps the -shm file into
memory, which network filesystems do not support reliably. webhooker does not
check this. An error reading disk I/O error (522) points at the storage under
DATA_DIR.
Configuration is not in DATA_DIR. It comes from the environment and from a
.env file in the working directory. Back those up with your deployment.
A running copy is not safe
Databases are held open for the life of the process. cp, rsync, tar or a
filesystem snapshot against a running service can catch a .db and its -wal
at two different instants. Use one of these two procedures.
Stop, copy, start. This is the only way to get one point in time across every file:
docker stop webhooker
cp -a /path/to/data /path/to/backup-$(date -u +%Y%m%dT%H%M%SZ)
docker start webhooker
SQLite online backup, one file at a time, with no downtime:
for db in /path/to/data/*.db; do
sqlite3 "$db" ".backup '/path/to/backup/$(basename "$db")'"
done
.backup reads through the WAL and writes one complete file. The runtime image
has no sqlite3, so run this on the host or from another container with the
volume mounted. Each file is captured at its own instant, so an event stored
between two copies lands in one file and not the other.
sqlite3 <db> .dump is an export, not a backup. It is safe to run but holds a
read transaction open for its whole duration.
Archive files are the exception. The archive writer closes and reopens its file
after a write, at most once per second. So you can move a finished archive file
away while the service runs. Move its -wal and -shm with it, or wait until
there are none. The next write recreates a missing archive file.
Restore
- Stop the service.
- Restore
webhooker.db, everyevents-*.dband everyarchive-*.dbtogether. A missingevents-{uuid}.dbis created empty at the next start, and the webhook comes back with its settings but no history. That start logscreated a new, empty databaseatWARNwith the file's path. A missing archive file is recreated silently at the next write. - Carry any
-waland-shmfiles that are in the backup. - Start the service. The container fixes the owner of the restored files.
Backups contain secrets
Treat a backup like the credentials inside it. Encrypt it at rest and restrict who can read it.
- The event and archive databases hold the full headers and body of every event, including whatever the sender put in them.
webhooker.dbholds target settings unencrypted, next to the session key and the password hashes.
Security
The entrypoint URL is the credential
Each entrypoint is a random version 4 UUID served at /h/{uuid}. Holding that
URL is what authorises a sender. The receiver checks nothing else: no shared
secret, no HMAC signature, no token header. None will be added. Signature
headers a sender adds anyway, such as X-Hub-Signature, are stored and
forwarded as ordinary headers.
What that means in practice:
- Keep the URL out of tickets, chat and screenshots. webhooker's own log never
writes it, except in the line a
logtarget writes. The reverse proxy's 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.
- 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, using 64 MB of memory, 1 pass, 4 threads, a 32-byte key and a 16-byte salt.
- Session cookies are encrypted,
HttpOnlyandSameSite=Lax. They carrySecurewhen the request arrived over TLS, judged per request from a direct TLS connection orX-Forwarded-Proto: https. - The session is regenerated at sign-in, which prevents session fixation.
- The session key is 32 random bytes made on first start and stored in
webhooker.db. - Every form is protected against cross-site request forgery with a token tied
to a cookie. Over TLS the form's
OriginorReferermust also match the host. - Form bodies are capped at 1 MB. A larger declared body is answered
413before anything reads it. - Every response carries
Strict-Transport-Security(unlessHSTS_MAX_AGEis0),X-Content-Type-Options: nosniff,X-Frame-Options: DENY, aContent-Security-Policyofdefault-src 'self'; script-src 'self'; style-src 'self',Referrer-Policy: strict-origin-when-cross-originand aPermissions-Policythat denies camera, microphone and location. - Authenticated pages are sent with
Cache-Control: no-store. /metricssits behind HTTP basic authentication.
Sign-in limits
The sign-in form has no rate limiter in front of it, on purpose. Behind a proxy
that TRUSTED_PROXIES does not cover, every client shares one bucket. A limiter
spent on arrival would let any stranger lock the operator out with a few
requests a minute.
Instead, the password is checked first, and only a failure is counted. A correct
password is never throttled. Failures are counted per client and submitted
username, 5 per minute, after which further failures are answered 429 with
Retry-After. A successful sign-in clears the count. At most 1024 username
counters and 1024 client counters are kept.
Two password checks can run at once. Each takes 64 MB of memory, so this caps
hashing at 128 MB. A request that gets no slot within 5 seconds, or arrives when
16 requests are already waiting, is answered 503 without hashing anything. The
password change form shares these slots.
Because the check runs before the count, what bounds online guessing is the two
slots, not the counter. A flood of wrong passwords is tried at the speed two
Argon2id checks allow. Choose a long random admin password. To put a cheaper
limit on top, rate-limit POST /pages/login at the reverse proxy, where the
real client address is visible.
An unknown username is checked against a dummy hash, so it costs the same time as a real one.
A flood can hold both slots and make sign-in answer 503 for as long as it
runs. Nothing accumulates: the operator's correct password works on the first
try once the flood stops. Restarting webhooker is no remedy. Block the source at
the 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. webhooker refuses it by default.
Every target URL must use http or https and resolve to a public address.
Private and reserved ranges are refused: loopback, the RFC 1918 ranges, carrier
NAT, link-local, multicast, documentation space, and the IPv6 unique local
range. One public address is refused too: 168.63.129.16, the Azure service
that hands a VM its credentials.
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 from handing a credential to another host.
ALLOWED_EGRESS_CIDRS opens named networks for the common case of forwarding to
your own network. Read ALLOWED_EGRESS_CIDRS before
setting it.
What the logs contain
webhooker writes structured logs to standard output: plain text on a terminal,
JSON otherwise. Each request writes one INFO line.
Fields a client can influence are cut to a fixed size, so one access log line is
at most 2,560 bytes. The query string is never logged. On the receiver route the
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,
and 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
TRUSTED_PROXIES.
SQL statements are logged with placeholders, never with the values bound to them. Session cookies, API keys and target credentials are never logged.
Two things are logged in the clear. The first is the admin password, once, on
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, headers and body, to the log. That is its
purpose.
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 with the request reduced to
this:
- the URL with its path replaced by the route pattern;
- the query string and the body replaced by
(redacted); - no cookies;
- only these headers:
Accept,Content-Length,Content-Type,Host,Origin,Referer(scheme and host only),User-AgentandX-Request-Id.
Configuration reference
All configuration comes from environment variables. For development, variables
can also go in a .env file in the working directory. It is read once at
startup. A variable set in the real environment wins over the file. A .env
file that cannot be parsed aborts startup, because a single bad line would
otherwise silently drop every variable in the file.
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. PORT=eighty,
DEBUG=ture and RETENTION_SWEEP_INTERVAL=1 hour all abort. Each section below
says what range its variable accepts.
A Go duration is a number with a unit, such as 30s, 15m, 24h or 1h30m.
The startup log line Configuration loaded shows every value except the
secrets. The /settings page shows the same.
WEBHOOKER_ENVIRONMENT
dev or prod. Default prod.
This controls one thing: CORS. In dev, every origin is allowed with
Access-Control-Allow-Origin: *, for local testing. In prod, no CORS headers
are sent. Cookie security and log format do not depend on it.
PORT
The TCP port the listener binds. Default 8080. It must be a number from 1
to 65535.
BIND_ADDRESS
The IP address the listener binds. Default 127.0.0.1. The Docker image sets
0.0.0.0.
The value must be an IP address literal. localhost is refused, because it may
mean either 127.0.0.1 or ::1. A value with a port, such as 127.0.0.1:8080,
is refused; the port belongs to PORT.
127.0.0.1answers only on this host. Use it with a reverse proxy on the same host.0.0.0.0answers on every IPv4 address and no IPv6 address.::answers on every IPv6 and IPv4 address.- A specific address such as
10.0.0.5answers on that interface only.
An address that parses but is not on this host passes configuration and fails at
bind time. The process logs the error and exits non-zero. The bindAddress
field of the startup log shows what was bound.
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 processes on
one directory would both run delivery recovery and deliver every pending event
twice. At startup the process takes an exclusive flock on
{DATA_DIR}/webhooker.lock and holds it until it exits. A second process
pointed at the same directory prints a message naming it and exits non-zero.
The kernel releases the lock when the process exits, even on kill -9. A
leftover webhooker.lock blocks nothing and must not be deleted by hand. To run
two webhookers on one host, give each its own DATA_DIR.
flock works between processes and containers that share a directory on one
host. It does not work across hosts on a network filesystem.
DEBUG
true or false. Default false. It accepts the spellings Go's
strconv.ParseBool accepts: 1, t, T, TRUE, true, True, 0, f,
F, FALSE, false, False. yes and on are refused.
DEBUG=true lowers the log level to DEBUG. That adds four kinds of line:
- every SQL statement, with placeholders rather than values;
- the lookup misses on the receiver and sign-in routes;
- the rate limiters' rejections;
- the records of building the dependency graph at startup.
The output is safe to paste into a bug report once the first-boot password has been changed.
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 and answers 404. With only one set,
startup fails with an error naming both variables.
SENTRY_DSN
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
How often the retention sweep and the archive sweep run. A Go duration greater
than zero. Default 1h.
The retention sweep deletes events older than each webhook's retention period, with their deliveries and attempts, in batches of 1000. The archive sweep prunes rows past each archive target's expiry.
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.
Only authenticated requests count as use. The idle timestamp in the cookie is rewritten at most once per tenth of the window, so a session can end up to 10% early, never late.
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. 1.5s and
-1h are refused. Read HSTS before changing it.
RECEIVER_RATE_LIMIT
How many requests per minute one client may send to one entrypoint. Default
120. It must be at least 1. A client over the limit is answered 429 with
Retry-After.
A second limit covers the whole /h/ route per client at ten times this value,
1200 by default. It exists because the route matches any single path segment. A
client inventing a new path per request would otherwise get a fresh bucket each
time. Each such request still costs a database lookup. The second limit is not
configurable on its own.
TRUSTED_PROXIES
A comma-separated list of networks in CIDR notation, such as
192.168.1.7, 2001:db8::5. 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. 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,
and the peer address is used instead. At most 64 hops are examined. Your proxy
must therefore append the peer address to X-Forwarded-For, as nginx's
$proxy_add_x_forwarded_for does.
If any client can reach webhooker, or the proxy in front of it, from a private
address, set the list to the proxy's address alone. Otherwise those clients can
choose their own X-Forwarded-For and bypass every rate limit. The address to
set is the remoteIP field of an http request log line for a request that
came through the proxy.
Behind a proxy the list does not cover, every client shares one bucket per limit. The receiver limits become service-wide ceilings. Sign-in failures from every client land in one count, though a correct password is still never throttled.
Rate-limit buckets are per IPv4 address and per IPv6 /64 prefix.
ALLOWED_EGRESS_CIDRS
A comma-separated list of networks in CIDR notation that delivery targets may
reach although the default rules refuse them. Default empty. A bare address
counts as a single host. Example: 10.0.0.0/8, 172.17.0.0/16.
This only ever adds destinations. The guard stays on, and nothing in the list
removes a destination that was already allowed. When the list is set, startup
logs it at WARN, blocks and all.
Each network you list is one that anyone with an admin account can make this
process send requests into, and read the responses from in the delivery log.
List the smallest blocks that cover your destinations. Prefer 10.1.2.3/32 over
10.0.0.0/8. 0.0.0.0/0 or ::/0 opens every private and reserved range at
once, including this host's own loopback. Do not list them.
Some addresses stay blocked whatever you list. They are link-local space, the unspecified addresses, and cloud metadata endpoints at fixed non-public addresses that hand out credentials:
169.254.0.0/16andfe80::/10, the link-local ranges.169.254.169.254is the metadata address on AWS, Azure, DigitalOcean, Hetzner, OpenStack and others.fd00:ec2::254/128andfd00:ec2::23/128, AWS metadata and EKS Pod Identity over IPv6.fd20:ce::254/128, Google Cloud metadata over IPv6.fd00:c1::a9fe:a9fe/128, Oracle Cloud metadata over IPv6.fd00:42::42/128, Scaleway metadata over IPv6.fd00:a9fe:a9fe::1/128, Linode and Akamai metadata over IPv6.100.100.100.200/32, Alibaba Cloud metadata, inside the carrier NAT range.192.0.0.192/32, Oracle Cloud Classic metadata.0.0.0.0/32and::/128, the unspecified addresses, which reach this host's loopback on Linux.::a9fe:a9fe/128and64:ff9b::a9fe:a9fe/128, two other spellings of169.254.169.254.
The six fd00::/8 entries matter because fd00::/8 is an ordinary block to
list for your own IPv6 network. Without them, that one line would open cloud
credentials on five providers.
This list is not every cloud's metadata address. If yours is not here, do not
list the block that contains it. Azure's public 168.63.129.16 is refused by
default but is reopened by a list that covers it.
Delivery reference
Target types
An http target posts the event body to its URL. The request carries the
event's Content-Type, the event's headers, then the target's configured
headers, then User-Agent: webhooker/{version}. A configured header replaces an
event header of the same name. These event headers are never forwarded: Host,
Connection, Keep-Alive, Transfer-Encoding, Te, Trailer, Upgrade,
Proxy-Authorization, Proxy-Connection and Content-Length. A 2xx response
is a success. The first 4 KiB of the response body is stored with the attempt.
An http target's settings:
url, the destination. Onlyhttpandhttpsare accepted.headers, oneName: valueper line.Host,Content-Length,Transfer-Encoding,Connection,TrailerandUser-Agentare refused, because the delivery sets them itself.Content-Typeis 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.forward_query, off by default. On, the event's query string is appended to the URL, joined with&if the URL already has one.
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 characters
is 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. It makes no network
request and never retries. A write failure fails the delivery.
A log target writes the event, headers and body included, as one INFO line.
It never retries.
Attempts and retries
http and slack targets take max_attempts, from 1 to 20. With 1, a failed
delivery is marked failed after its one attempt. With more, 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 delivery has one of four statuses. pending means created but not yet
attempted. retrying means at least one attempt failed and more remain.
delivered means an attempt succeeded. failed means every allowed attempt
failed.
Every attempt is recorded with its number, success, status code, response body, error and duration. One case is not recorded: an attempt cut off by shutdown.
Circuit breaker
An http or slack target with max_attempts above 1 has a circuit breaker. A
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. 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.
Workers and queues
Ten workers carry out deliveries. New deliveries wait in one queue and retries in another. Each queue holds 10,000 tasks. At most ten deliveries are in flight at once, however deep the queues get.
When a retry falls due and its queue is full, the retry is dropped and the
delivery stays retrying in the database. The recovery sweep picks it up.
Each task carries the target's settings and the event. A body under 16 KiB travels with the task. A larger one is read from the database when the attempt runs.
Recovery
A recovery routine runs once at start and then every 60 seconds. For every
webhook it reads the deliveries still pending or retrying. One that already
has a successful attempt recorded is marked delivered and not sent again. A
retrying delivery is scheduled for whatever remains of its wait. A pending
one is queued. A delivery the engine is already working on is left alone.
At start, every pending delivery is taken, however recent. So a delivery cut
off by a stop moments earlier is sent again at once. The periodic sweep takes
only pending deliveries untouched for 15 minutes, at most 500 per webhook each
time.
If a target was deleted while a delivery to it was still open, recovery marks
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 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
at the next start. A database or log task finishes as normal.
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. Docker's default is 10. A shorter grace kills the process before the shutdown can run.
Archives
An archive target writes to
archive-{webhook_name}-{target_name}-{target_uuid}.db in DATA_DIR. The two
names are made safe for a file name:
- lowercased;
- letters and digits kept, and every other run of characters turned into one
-; - cut to 40 characters;
unnamedwhen nothing is left.
A webhook named Orders (EU) with a target named Long-term archive writes
archive-orders-eu-long-term-archive-{target_uuid}.db.
Each row holds the event's id, webhook id, entrypoint id, method, query string, headers, body, content type and the time it was archived.
archive_rotation is none by default, which keeps one file. monthly,
daily and hourly start a new file for each period of the event's receive
time, in UTC, with the period before .db: -2026-10.db, -2026-10-01.db or
-2026-10-01-19.db. A changed rotation applies from the next event. Files
already written keep their names and are still shown, pruned and downloaded.
archive_expiry is never by default. The forms offer 1h, 12h, 24h,
30d, 90d and 365d. Rows older than the expiry are deleted each time the
file is opened for a write, and by the archive sweep every
RETENTION_SWEEP_INTERVAL. The sweep deletes a period file it has emptied. It
never creates a file.
Renaming the webhook or the target renames every file of the target. If a file with the new name already exists, the edit is refused and nothing moves. A file the operator has moved away is skipped.
Deleting a webhook or a target leaves its archive files on disk. Removing them is the operator's decision.
Download on the webhook page returns the target's whole archive as one
gzipped JSON file named
archive-{webhook_name}-{target_name}-{YYYYMMDDTHHMMSSZ}.json.gz. It holds
webhook and target (each an id and a name), exported_at, and
archived_events, one object per row with every column. Rows from a period file
carry a period field. A body that is not valid UTF-8 is written in base64 with
"body_encoding": "base64" beside it. The download streams one file at a time
and creates nothing.
Data reference
Files
webhooker.db holds the application tier: settings, users, API keys, webhooks,
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 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
created a new, empty database at WARN. On a deployment that has run before,
that line means the file was lost, most often because the volume holding
DATA_DIR is not mounted. A damaged file fails that webhook alone, with an
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. Deleting an entrypoint or a
target soft-deletes it.
Schema
Each kind of database file has its schema in SQL files built into the binary,
under internal/db/migrations/: main/ for webhooker.db, events/ for event
databases and archive/ for archive files. In each, 000_migration.sql creates
the schema_migrations table and 001_schema.sql holds the schema. Opening a
database applies every file its schema_migrations does not list, in one
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 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.
User has username (unique, at most 1024 bytes) and password (the Argon2id
hash). The first start creates admin with a random 16-character password.
Webhook has user_id, name, description and retention_days.
retention_days is 0 for forever, or a whole number from 1 to 106751. The cap
keeps the retention cutoff inside the range of a Go duration.
Entrypoint has webhook_id, path, description and active. path is the
bare UUID. The /h/ prefix is route only and is not stored.
Target has webhook_id, name, type, active, url, headers,
timeout_seconds, forward_query, archive_expiry, archive_rotation and
max_attempts. type is one of http, slack, database or log. Each type
fills only its own settings. archive_expiry is stored in nanoseconds, 0 for
never.
APIKey has user_id, key, description and last_used_at. The table
exists, but nothing creates a key and nothing checks one.
Event tier
Event has webhook_id, entrypoint_id, method, raw_query, headers (as
JSON), body, content_type, body_bytes and resubmitted_from_id. method
is always POST. resubmitted_from_id names the event a resubmit copied; it is
empty for an event that arrived on the receiver.
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 delivery_id, attempt_num, success,
status_code, response_body (the first 4 KiB), error and duration in
milliseconds.
Three tables hold running totals so the pages need not count rows. EventTotals
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
Public routes:
GET /redirects to/hookswhen signed in and to/pages/loginotherwise.GET /.well-known/healthcheckreturns JSON withstatus,now,uptimeSeconds,uptimeHuman,versionandappname.GET /s/*andHEAD /s/*serve the embedded CSS and JavaScript. Other methods get405withAllow: GET, HEAD.POST /h/{uuid}is the receiver. Any other method gets405withAllow: POST. An unknown UUID gets404, a deactivated entrypoint410, a body over 1 MB413, and a stored event200with{"status":"ok"}.
Sign-in routes:
GET /pages/loginshows the sign-in form. Itsnextparameter names the page to return to. Anything but a path on this site is replaced with/.POST /pages/loginsigns in. See Sign-in limits.POST /pages/logoutsigns out.
Routes behind the sign-in. A GET without a session is redirected to
/pages/login with the path as next:
GET /user/{username}is the profile page. Another user's name gets403.POST /user/{username}/passwordchanges the password, 5 per minute per client.GET /settingslists the loaded configuration.GET /hookslists webhooks.GETandPOST /hooks/newcreate one.GET /hook/{id}is the webhook page.GETandPOST /hook/{id}/editedit it.POST /hook/{id}/deletedeletes it.GET /hook/{id}/eventsis the event log.?show=failedand?show=pendingnarrow it.GET /hook/{id}/events/{eventID}is one event's page, andGET /hook/{id}/events/{eventID}/bodydownloads its stored body.POST /hook/{id}/deliveries/{deliveryID}/replayreplays a delivery, andPOST /hook/{id}/events/{eventID}/resubmitresubmits an event, each 30 per minute per client.POST /hook/{id}/entrypointsadds an entrypoint.POST /hook/{id}/entrypoints/{entrypointID}/edit,/deleteand/toggleedit, delete, and activate or deactivate one.POST /hook/{id}/targetsadds a target.GETandPOST /hook/{id}/targets/{targetID}/editedit one.POST .../deleteandPOST .../toggledelete, and activate or deactivate one.GET /hook/{id}/targets/{targetID}/downloaddownloads an archive.
GET /metrics exists only when METRICS_USERNAME and METRICS_PASSWORD are
set, behind basic authentication.
/api/v1 is mounted with no routes. Every path under it answers 404.
Metrics reference
/metrics serves one Prometheus registry. It carries the Go runtime and process
collectors, the delivery metrics below, and three inbound HTTP metrics.
Every delivery metric carries one label, target_type, with one of http,
slack, database, log or unknown. Target ids and event ids are never
labels, because a label value that grows with traffic would grow the registry
without bound.
webhooker_events_received_total(counter, no label): events stored, resubmitted copies included.webhooker_delivery_attempts_total(counter): attempts sent to a target. A delivery the circuit breaker turned away is not an attempt.webhooker_deliveries_succeeded_total(counter): deliveries that becamedelivered.webhooker_deliveries_failed_total(counter): deliveries that becamefailed.webhooker_delivery_retries_total(counter): deliveries put intoretrying.webhooker_delivery_replays_total(counter): deliveries created by Replay.webhooker_events_resubmitted_total(counter, no label): events created by Resubmit.webhooker_delivery_duration_seconds(histogram): wall time of one attempt.webhooker_deliveries_pendingandwebhooker_deliveries_retrying(gauges): deliveries in those statuses, counted from the databases every 30 seconds. Deliveries to a deleted target count underunknown.webhooker_circuit_breakers_open(gauge): breakers currently open.
The outcome counters move only after the status change has been written.
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
for the whole service, with handler set to (all).
Development
Prerequisites
- The Go version named in
go.mod. - Docker. The tests, the linters, the formatters and the stylesheet build all run in Docker.
Nothing else is installed on the host. golangci-lint, ESLint, prettier, node,
yarn and the Tailwind CLI run only inside Docker, at the versions pinned in the
Dockerfile, package.json and yarn.lock.
Commands
make bootstrap # Install git, make, Go and the Go modules (idempotent)
make setup # bootstrap, then install the git pre-commit hook
make assets # Extract Alpine.js from 3p/ to static/js/alpine.min.js
make fmt # Format Go (gofmt, goimports) and Markdown (prettier, in Docker)
make fmt-check # Fail if gofmt or prettier would change anything
make lint # Run gofmt, golangci-lint and ESLint in Docker
make test # Run the tests in Docker, with cgo off
make test-browser # Run the browser test in Docker (Dockerfile.browser)
make check # test, lint, fmt-check and css-check: the CI gate
make build # Build bin/webhooker, version-stamped
make version # Print the version this checkout would stamp
make run # build, then run ./bin/webhooker
make dev # go run ./cmd/webhooker
make deps # go mod download, then go mod tidy
make docker # Build the Docker image
make hooks # Install the git pre-commit hook
make css # Regenerate static/css/tailwind.css in Docker
make css-check # Fail if static/css/tailwind.css is stale
make clean # Remove bin/
The repository follows
Scripts to Rule Them All.
Each make target above, except build, run, dev, deps, version and
clean, is a thin shim over a script of the same name in script/.
script/cibuild is the CI entrypoint: it runs script/bootstrap, then
script/check, then builds the image. script/precommit checks that
go mod tidy changes nothing, then runs script/check.
The tests run with cgo off and so without Go's race detector.
How the checks run
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 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 added. It has no sqlite3.
The check workflow in .gitea/workflows/check.yml checks out the repository
and runs script/cibuild, nothing else.
Version stamping
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 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
checkout, which gives:
- the tag at a clean tagged commit, such as
v1.0.0; - the tag, the commit count and the short hash past a tag, such as
v1.0.0-3-g1a2b3c4; - the short hash alone when no tag is reachable;
- a
-dirtysuffix on any of these for uncommitted changes.
With no git metadata it is unknown.
make build VERSION=v1.2.3 overrides it. script/docker and script/cibuild
run the same git describe on the host and pass the result in as the VERSION
build argument. Without that argument the image build reads the .git in the
build context, and fails if .git is present but the version comes out empty,
dev or unknown. .dockerignore leaves out every git config file, which
can carry a credential, and keeps the rest of .git.
make build passes -trimpath and -s -w, so the binary carries no build
paths, symbol table or debug information. Nothing that differs between two
builds of one commit is stamped in.
Third-party browser assets
The pages use one third-party script, Alpine.js, 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 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 go:embed picks up. 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
js/alpine.min.js.
To move to a new version:
- Download
https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz. - Check it against the
dist.integrityhash athttps://registry.npmjs.org/@alpinejs/csp/<version>. - Replace the tarball in
3p/and update the file name inscript/assets. - Run
make checkandmake test-browser.
Browser test
internal/server has a test that loads the pages in a headless browser 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
static/css/tailwind.css is generated by Tailwind and committed. To change the
styles, edit the templates, static/js/app.js,
internal/handlers/recent_events.go or static/css/input.css, then run
make css and commit the result. Tailwind reads classes only from the files
that input.css names in its @source lines. make check fails when the
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.
Libraries
- Uber fx wires the components and runs their start and stop hooks.
- chi routes HTTP.
- GORM with modernc.org/sqlite, a pure Go SQLite driver, handles the databases.
log/slogfrom the standard library writes the logs.- gorilla/sessions and gorilla/csrf handle sessions and form protection.
- go-chi/httprate rate-limits the receiver, the password change, replay and resubmit.
- Prometheus client and go-http-metrics serve the metrics.
- Sentry reports errors when configured.
Package layout
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.
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 onDATA_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 ofslog, 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:slogsetup 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 aRefererto its scheme and host.internal/reqtls: the one predicate that decides whether a request is TLS.internal/resetpw: thewebhooker resetpwsubcommand.internal/server: the HTTP server, routes, middleware order and Sentry.internal/session: the cookie session.internal/versionscript: tests forscript/versionand 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: request id, security headers, access
log, metrics (when /metrics is served), CORS, a 60 second request timeout,
panic recovery, and Sentry when configured. Each page route group adds, in
order, its own panic recovery, the 1 MB body cap, CSRF,
Cache-Control: no-store, and the sign-in check. The body cap comes before CSRF
because CSRF parses the form.
License
MIT.