Rewritten from scratch, about 4,600 words, every statement checked against the code. Part of #558; passed independent review in round 10. Model: fable-5-1 (writing); opus-5-5 (review, merge)
33 KiB
webhooker
webhooker is an MIT-licensed Go web service by @sneak 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.
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:
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 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
httptarget posts each event to a URL, with optional Headers and a Timeout. - A
slacktarget 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
logtarget 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.
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 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.
- Keep the app port private. Only the proxy may reach it:
127.0.0.1on a bare host, or a port published to127.0.0.1for the image. - Send
X-Forwarded-Proto: https. This header tells webhooker the request arrived over TLS. It decides theSecureflag on cookies, the flag that makes a browser send them only over HTTPS. It also decides whether a form submission must carry a matchingOriginorRefererheader. A browser sends those two headers to say which site or page a request came from. webhooker readsX-Forwarded-Protofrom any connecting machine, so the proxy must overwrite whatever the client sent. - 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 as172.17.0.1. The default list covers it. The list does not cover loopback, so on a bare host setTRUSTED_PROXIES=127.0.0.1. - Pass on the client's
Hostheader with its port.Hostis the header that names the site the client asked for. webhooker checks a form'sOriginagainst theHostheader it receives. AHostwithout the port makes every form submission fail with403on any port other than443. 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:
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.
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:
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), theadminaccount'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:
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) andSameSite=Lax(not sent with cross-site form posts). They carrySecure(sent only over HTTPS) when the request carriesX-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
Originmust name this host. - Form bodies are capped at 1 MB.
- Every response carries these headers:
Strict-Transport-Security, the HSTS header, unlessHSTS_MAX_AGEis0;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.
/metricssits 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/8and::1; - the RFC 1918 private ranges,
10.0.0.0/8,172.16.0.0/12and192.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 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 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/16andfe80::/10;fd00:ec2::254/128andfd00: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/32and::/128;::a9fe:a9fe/128and64: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/healthcheckreturns JSON with the version and the uptime.POST /h/{uuid}is the receiver. Its answers:- any method but
POSTgets405; - 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
200with{"status":"ok"}.
- any method but
GET /pages/loginshows the sign-in form,POST /pages/loginsigns in, andPOST /pages/logoutsigns out.- The pages behind the sign-in live under
/user/,/settings,/hooksand/hook/. AGETto one of them without a session is redirected to/pages/login. GET /metricsexists only whenMETRICS_USERNAMEandMETRICS_PASSWORDare 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_totalandwebhooker_events_resubmitted_total, two counters (totals that only grow) with no label;webhooker_delivery_attempts_total;webhooker_deliveries_succeeded_totalandwebhooker_deliveries_failed_total;webhooker_delivery_retries_totalandwebhooker_delivery_replays_total;webhooker_delivery_duration_seconds, a histogram (a distribution) of how long one attempt took;webhooker_deliveries_pendingandwebhooker_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.
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. 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
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.
script/bootstrapinstalls git, make, Go and the Go modules.script/setuprunsscript/bootstrap, thenscript/install-precommit.script/projectnameprintswebhooker.script/assetsextracts Alpine.js from3p/tostatic/js/alpine.min.js.script/testruns the tests in Docker.script/test-browserruns the browser test in Docker.script/lintrunsgofmt,golangci-lintand ESLint in Docker.script/fmtformats the Go code withgofmton the host and the Markdown withprettierin Docker. It also runsgoimportswhen that is installed.script/fmt-checkfails whengofmtorprettierwould change a file. It does not rungoimports, and it writes nothing.script/cssregeneratesstatic/css/tailwind.cssin Docker.script/css-checkfails whenstatic/css/tailwind.cssis out of date.script/checkrunsscript/test,script/lint,script/fmt-checkandscript/css-check.script/versionprints the version to stamp into the binary.script/dockerbuilds the image taggedwebhooker.- CI is the check that runs on every push. It runs
script/cibuild:script/bootstrap,script/check, then the image build. script/precommitchecks thatgo mod tidychanges nothing, then runsscript/check.script/install-precommitinstalls the git hook that runsscript/precommitbefore each commit.
TODO
See TODO.md.
License
MIT.