diff --git a/Dockerfile b/Dockerfile index e72dee7..3047b21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,8 +76,6 @@ RUN --network=none node_modules/.bin/prettier --check '**/*.md' # depends on it. # # golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24 -# Using Debian-based image because mattn/go-sqlite3 (CGO) does not -# compile on Alpine musl (off64_t is a glibc type). FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint WORKDIR /src @@ -167,9 +165,7 @@ RUN --mount=type=tmpfs,target=/tmp/tests,size=512m \ # Build stage # golang:1.26.1-bookworm (Debian-based), 2026-03-17 -# Using Debian-based image because gorm.io/driver/sqlite pulls in -# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. The image -# ships git and make, which the version step below uses. +# The image ships git and make, which the version step below uses. FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder # Nothing is wanted from the lint and test phases or from the stylesheet and @@ -211,26 +207,11 @@ RUN version="$(make version VERSION="$VERSION")"; \ fi # Builds through the Makefile's build target, which runs script/assets -# (Alpine.js, extracted from its tarball in 3p/) first. +# (Alpine.js, extracted from its tarball in 3p/) first. The target builds +# with cgo off, so the binary is statically linked and runs on the Alpine +# runtime image below as it is. RUN make build VERSION="$VERSION" -# Rebuild with static linking for the Alpine runtime. The cgo binary the -# step above builds is dynamically linked against glibc, which Alpine -# (musl) does not have. -# -# A statically linked glibc still loads shared libraries at run time for -# getaddrinfo and dlopen, and Alpine has none to load, so the build tags -# keep both out of the binary: netgo and osusergo select Go's own DNS -# resolver and user lookup instead of glibc's, and -# sqlite_omit_load_extension leaves out SQLite extension loading, which -# webhooker never uses. -# -# The static flags and tags go in through GO_LDFLAGS and GO_TAGS rather -# than a -ldflags of their own: the build target composes them with the -# -X that stamps the version, so this relink cannot silently drop the -# stamp. -RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_TAGS=netgo,osusergo,sqlite_omit_load_extension GO_LDFLAGS='-extldflags "-static"' - # Runtime stage # alpine:3.21, 2026-03-17 FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 @@ -274,8 +255,17 @@ EXPOSE 8080 # port. ENV BIND_ADDRESS=0.0.0.0 +# The probe requests the container's own address, not loopback, so a +# listener that answers only on loopback (an empty or loopback +# BIND_ADDRESS) fails it. On a network with IPv4, hostname -i prints the +# container's IPv4 address, which 0.0.0.0 and :: both answer. On an +# IPv6-only network it prints none, and the probe requests the IPv6 +# address /etc/hosts lists under the container's hostname instead, which +# only :: answers. HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1 + CMD addr="$(hostname -i 2>/dev/null | cut -d ' ' -f 1)"; \ + [ -n "$addr" ] || addr="[$(getent hosts "$(hostname)" | cut -d ' ' -f 1)]"; \ + wget --no-verbose --tries=1 --spider "http://$addr:8080/.well-known/healthcheck" || exit 1 ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["/app/webhooker"] diff --git a/Makefile b/Makefile index b2f2f36..e52a12b 100644 --- a/Makefile +++ b/Makefile @@ -16,17 +16,6 @@ VERSION ?= $(shell script/version) # exists to correct. override VERSION := $(or $(strip $(VERSION)),$(shell script/version)) -# Extra linker flags and build tags for the build target. The static -# relink in the Dockerfile passes -extldflags and its build tags through -# these rather than a -ldflags of its own, so composing flags cannot drop -# the version stamp. -# -# The build target itself always passes -trimpath and -s -w, as the Go -# Dockerfile in REPO_POLICIES.md does: no build paths, symbol table or -# debug information in the binary. -GO_LDFLAGS ?= -GO_TAGS ?= - bootstrap: @script/bootstrap @@ -54,8 +43,12 @@ fmt-check: check: @script/check +# Built with cgo off, -trimpath and -s -w, as the Go Dockerfile in +# REPO_POLICIES.md does: the binary carries no build paths, symbol table or +# debug information, and on Linux, the image's included, it is statically +# linked. build: assets - go build -trimpath -tags '$(GO_TAGS)' -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker + CGO_ENABLED=0 go build -trimpath -ldflags '-s -w -X main.version=$(VERSION)' -o bin/webhooker ./cmd/webhooker run: build ./bin/webhooker diff --git a/README.md b/README.md index 80ed3db..7eafaff 100644 --- a/README.md +++ b/README.md @@ -310,16 +310,17 @@ the namespace, whatever the process bound. Exposure is decided at the publish flag instead — `-p 127.0.0.1:8080:8080` rather than `-p 8080:8080` — which is the operator's to choose and is what [Running with Docker](#running-with-docker) shows. A loopback bind inside a container buys nothing and makes the process -unreachable through its own published port. +unreachable through its own published port. A container on an IPv6-only Docker +network has no IPv4 address for `0.0.0.0` to answer on, so there it needs +`BIND_ADDRESS=::`. The value must be an IP address literal: - `127.0.0.1` — loopback only (the binary's default). Use this with a reverse proxy on the same host. -- `0.0.0.0` — every IPv4 address. The image's default; on a bare host, only - behind a firewall on the port. -- `::` — every address, IPv6 and (on Linux, with the default - `net.ipv6.bindv6only=0`) IPv4 as well. +- `0.0.0.0` — every IPv4 address and no IPv6 address. The image's default; on a + bare host, only behind a firewall on the port. +- `::` — every IPv6 address and every IPv4 address. - A specific address such as `10.0.0.5` — that interface only. An **empty** value is treated as unset, as everywhere else here, and takes the @@ -340,7 +341,8 @@ machine that has no such interface — is a valid literal, so it reaches the listener and fails there. The process logs the bind error and exits non-zero rather than staying up with nothing listening. The effective value is in the `bindAddress` field of the startup log line, which is the way to check what a -running deployment actually bound. +running deployment actually bound. On a bare host, `ss -ltn` lists the socket +itself as `0.0.0.0:8080` for a `0.0.0.0` bind and as `*:8080` for `::`. #### Metrics credentials @@ -668,9 +670,9 @@ Either set a literal or leave the variable out entirely. Overriding `BIND_ADDRESS` to a loopback address in a container — by that route or deliberately — makes the container unreachable from outside its namespace even with `-p`. The published port answers nothing, and the health check fails -too: it requests `http://localhost:8080`, `localhost` resolves to `::1` first, -and a `127.0.0.1` bind is not listening there. The container then goes -`unhealthy` about **65 seconds** after start — from +too: it requests the container's own address (its IPv4 address, or its IPv6 +address on an IPv6-only network), where a loopback bind is not listening. The +container then goes `unhealthy` about **65 seconds** after start — from `HEALTHCHECK --start-period=5s --interval=30s --retries=3`, so failing probes at 5s, 35s and 65s, and `unhealthy` on the third. (Docker's probe cadence during the start period has changed between versions; re-derive from those three values @@ -726,8 +728,8 @@ the credentials. address (directly, or through anything that can rewrite source addresses, such as NAT or a published container port). The `remoteIP` field of the `http request` log line for a request that came through the proxy shows - that address; the health check's own lines show `::1`. See - [Trusted proxies](#trusted-proxies). + that address; the health check's own lines show the container's own + address. See [Trusted proxies](#trusted-proxies). - Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets `BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to `/var/lib/webhooker`. - Everything else is optional; see [Configuration](#configuration). @@ -802,7 +804,12 @@ comes up, serves pages, and is broken in a way nothing reports. proxy's log names it regardless. nginx's default `combined` format already logs `$remote_addr`; do not replace it with one that drops the client address, and retain those logs as long as you would want to answer a question - about traffic. + about traffic. That log also records the full request path, so it holds the + entrypoint UUID of every event a sender posts. webhooker's own log does not, + apart from the line a `log` target writes. Protect the proxy's log as you + would the entrypoint URLs themselves: anyone who can read it can post events + to every entrypoint in it (see + [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret)). ### nginx @@ -828,7 +835,9 @@ server { client_max_body_size 1m; # $remote_addr is the client. webhooker's own log names it, as - # clientIP, only while TRUSTED_PROXIES covers this proxy. + # clientIP, only while TRUSTED_PROXIES covers this proxy. The + # request path in this log holds the entrypoint UUID, so protect + # the file like the entrypoint URL itself. access_log /var/log/nginx/webhooker.access.log combined; location / { @@ -1160,7 +1169,16 @@ What that means for an operator: - **The URL is a capability, so treat it as a secret.** Keep it out of logs, ticket bodies, chat messages and screenshots. Anyone who reads it anywhere can - post events as that sender. + post events as that sender. Apart from a `log` target's line, webhooker's own + log lines never carry the UUID: they name a request to the receiver by the + route pattern `/h/{uuid}`, the `webhook request received` line names the + entrypoint by its `entrypoint_id`, and the access log keeps only the scheme + and host of a `Referer`. A `log` target writes the request's headers as + received, so a `Referer` naming the entrypoint URL appears in its line, as it + does when a sender has followed a redirect from `http://` to `https://`. The + reverse proxy's access log does carry the UUID, in the request path, so it + must be protected like the URL itself (see + [Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)). - **Rotating means minting a new entrypoint, not changing a key.** There is no way to rotate the UUID in place. To retire one, delete the entrypoint (or deactivate it, which answers `410`) and create a new one, then point the @@ -1235,34 +1253,37 @@ markup. The CSP build runs no expressions, so every Alpine directive in `static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never `x-data="{ open: false }"` or `@click="open = !open"`. -A browser test in `internal/server` loads the webhook page, its edit pages and -the event log under the real policy and checks that: the add entrypoint form -stays hidden until Add is clicked; for every target type, the targets section's -Add shows only a choice of type with Next and Cancel, Next shows only that -type's fields (no url field for `database` or `log`), Cancel at either step -closes the form, and saving adds the target; a refused target comes back with -its form open, the values entered and the reason, and after Cancel the next Add -starts with an empty form and no reason; a refused save on the target edit page -and on the webhook edit page comes back with the reason and every value entered; -the Copy button beside an entrypoint URL reads "Copied" once clicked; an -entrypoint's Edit button shows its edit form in place of its description and -hides until the form closes, Cancel hides the form and drops what was typed, as -does leaving the page and going back to it, and Save changes the description; of -the recent events on the webhook page only the newest starts expanded, each -expands and collapses, and Open leads to the event's own page; of the events in -the event log only the newest starts expanded, and an event there expands and -collapses when its row's caret or its ID is clicked, and from the keyboard, but -not when its ID is selected with the mouse, and a delivery's attempts inside it -expand and collapse; and at phone width the menu button opens and closes the -mobile menu, and neither the webhook page nor the event log, with a delivery's -attempts open, scrolls sideways or cuts anything off at the page's or a card's -edge. It also fails if the browser reports a console warning or error, an -uncaught exception, or anything the policy refused. `make check` and the image -build lint it but do not run it, and `make test` leaves it out (its file is -built only with the `browser` build tag). Run it with `make test-browser` after -changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, which -runs the test in a digest-pinned headless browser image, so the host needs no -browser. +A browser test in `internal/server` loads the sign-in page, the webhook page, +its edit pages, the event log, the webhook list, the settings page, the profile +page and the `404` error page shown for a path that does not exist, under the +real policy and checks that: the add entrypoint form stays hidden until Add is +clicked; for every target type, the targets section's Add shows only a choice of +type with Next and Cancel, Next shows only that type's fields (no url field for +`database` or `log`), Cancel at either step closes the form, and saving adds the +target; a refused target comes back with its form open, the values entered and +the reason, and after Cancel the next Add starts with an empty form and no +reason; a refused save on the target edit page and on the webhook edit page +comes back with the reason and every value entered; the Copy button beside an +entrypoint URL reads "Copied" once clicked; an entrypoint's Edit button shows +its edit form in place of its description and hides until the form closes, +Cancel hides the form and drops what was typed, as does leaving the page and +going back to it, and Save changes the description; of the recent events on the +webhook page only the newest starts expanded, each expands and collapses, and +Open leads to the event's own page; of the events in the event log only the +newest starts expanded, and an event there expands and collapses when its row's +caret or its ID is clicked, and from the keyboard, but not when its ID is +selected with the mouse, and a delivery's attempts inside it expand and +collapse; at phone width the menu button opens and closes the mobile menu, and +neither the webhook page nor the event log, with a delivery's attempts open, +scrolls sideways or cuts anything off at the page's or a card's edge; and a +target's Delete asks first, naming the target, sends nothing when the answer is +no and deletes the target when it is yes. It also fails if the browser reports a +console warning or error, an uncaught exception, or anything the policy refused. +`make check` and the image build lint it but do not run it, and `make test` +leaves it out (its file is built only with the `browser` build tag). Run it with +`make test-browser` after changing `templates/` or `static/js/`: that builds +`Dockerfile.browser`, which runs the test in a digest-pinned headless browser +image, so the host needs no browser. The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for byte as the npm registry publishes it. It is a dependency, not this repo's build @@ -1295,7 +1316,9 @@ and commit the regenerated file with the change. Tailwind takes classes only from the files that `input.css` names in its `@source` lines; a class written in any other file is not generated until that file is named there too. `make check` and the image build fail when the committed file differs from what `make css` -generates. `static/css/style.css` is hand-written and is not generated. +generates. `static/css/style.css` is hand-written and is not generated. The +pages' Content-Security-Policy refuses inline styles, so a template styles an +element with a class, never a `style` attribute. `make css` runs the Tailwind standalone CLI in Docker, at the version and sha256 pinned in the Dockerfile's stylesheet stages; it is never installed on the host. @@ -1317,20 +1340,22 @@ webhooker solves this by acting as a durable intermediary: nothing is lost if downstream targets are unavailable. 2. **Guaranteed delivery** — Events are queued for delivery to each configured - target. Failed deliveries are retried with configurable backoff. Every - delivery attempt is logged with status codes, response bodies, and timing. + target. Failed deliveries are retried with configurable backoff. Delivery + attempts are logged with status codes, response bodies, and timing. **That guarantee is at-least-once, not exactly-once.** When a send reaches its target but the write recording that outcome fails, the delivery is deliberately left in a recoverable state rather than marked done — losing a - delivery is the worse failure — so the pending sweep picks it up about - fifteen minutes later, or the next restart does, and the target receives a - payload it already got. webhooker adds no delivery identifier of its own to - an outbound request, so **make your receiver idempotent** against whatever - the payload itself carries. + delivery is the worse failure — so the sweep picks it up about fifteen + minutes later, or the next restart does, and the target receives a payload + it already got. A send still in flight when the service stops is left the + same way: its attempt is not recorded, and the next start sends it again + (see Shutdown). webhooker adds no delivery identifier of its own to an + outbound request, so **make your receiver idempotent** against whatever the + payload itself carries. 3. **Observability** — Full request/response logging for every webhook received - and every delivery attempted. Prometheus metrics expose volume, latency, and + and for delivery attempts. Prometheus metrics expose volume, latency, and error rates. The web UI provides real-time visibility into event flow. 4. **Fan-out** — A single incoming webhook can be delivered to multiple targets @@ -1368,10 +1393,10 @@ It uses: management - **[go-chi](https://github.com/go-chi/chi)** for HTTP routing - **[GORM](https://gorm.io)** for database access with - **[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)** as the runtime - SQLite driver. Note: `gorm.io/driver/sqlite` transitively depends on - `mattn/go-sqlite3`, which requires CGO at build time (see [Docker](#docker) - section) + **[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)**, which is pure + Go, as the SQLite driver. `gorm.io/driver/sqlite` also imports + `mattn/go-sqlite3`, whose stub for builds without cgo is linked into the + binary but never used - **[slog](https://pkg.go.dev/log/slog)** (stdlib) for structured logging with TTY detection (text for dev, JSON for prod) - **[gorilla/sessions](https://github.com/gorilla/sessions)** for encrypted @@ -1494,47 +1519,35 @@ the rotation chosen below that (none, monthly, daily or hourly). Both are validated as on the add target form, and the webhook and its targets are created together or not at all. -| Field | Type | Description | -| ---------------- | ------- | ----------------------------------------------------------- | -| `id` | UUID | Primary key | -| `user_id` | UUID | Foreign key → User | -| `name` | string | Human-readable name | -| `description` | string | Optional description | -| `retention_days` | integer | Days to retain events (default: 30; 0 means retain forever) | +| Field | Type | Description | +| ---------------- | ------- | --------------------------------------------- | +| `id` | UUID | Primary key | +| `user_id` | UUID | Foreign key → User | +| `name` | string | Human-readable name | +| `description` | string | Optional description | +| `retention_days` | integer | Days to retain events; 0 means retain forever | **Relations:** Belongs to User. Has many Entrypoints. Has many Targets. The `retention_days` field controls how long event data is kept in the webhook's dedicated database before automatic cleanup. -Setting `retention_days` to `0` means "retain events forever". Because the -column carries a default of 30, a literal zero cannot survive an insert, so a -zero is rewritten on save to a sentinel of `365 * 1000` days -(`database.RetentionForeverDays`). The retention reaper recognises that sentinel -and skips the webhook entirely, and the web UI displays such a webhook's -retention as "forever" rather than as a day count. +The value is stored as entered. Setting `retention_days` to `0` means "retain +events forever": the retention reaper skips the webhook entirely, and the web UI +displays its retention as "forever" rather than as a day count. -Submitted `retention_days` values therefore fall into three bands, not two: - -- `1` up to `database.MaxFiniteRetentionDays` (106751 days, about 292 years) is - accepted as a finite retention. -- Above that ceiling but below the retain-forever sentinel of 365000 - (`database.RetentionForeverDays`) is rejected with a 400. This is the band the - cap exists for. -- `0`, and `365000` or above, are accepted and mean retain forever, collapsing - to the sentinel — `0` in `Webhook.BeforeSave`, the large values in - `parseRetentionDays`. The large values are not out of range: the edit form - pre-fills the sentinel for a retain-forever webhook, so submitting that form - back unchanged has to keep meaning "forever". - -A negative value is in none of the three: `parseRetentionDays` rejects it with a -400 before `BeforeSave` ever sees it. +The new webhook and edit forms accept `0`, or a whole number of days from `1` up +to `database.MaxFiniteRetentionDays` (106751 days, about 292 years). Anything +else, a negative value or one above that cap included, is rejected with a 400. A +blank field gives the default of 30 days (`database.DefaultRetentionDays`) on +the new webhook form, and leaves the stored value unchanged on the edit form. The cap is not arbitrary: the reaper computes its cutoff as a `time.Duration`, an int64 nanosecond count, and a longer period overflows it. An overflowed cutoff lands in the future, where it matches every row, so the sweep would delete every event the webhook has instead of none. The reaper also clamps the -value it reads to the cap, so no stored value can trigger that either. +value it reads to the cap, and reads a negative value as forever, so no stored +value can trigger that either. #### Entrypoint @@ -1566,31 +1579,34 @@ did not arrive on the URL and counts in neither. A delivery destination for events. Each target defines where and how events should be forwarded. -| Field | Type | Description | -| ------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | UUID | Primary key | -| `webhook_id` | UUID | Foreign key → Webhook | -| `name` | string | Human-readable name | -| `type` | TargetType | One of: `http`, `slack`, `database`, `log` | -| `active` | boolean | Whether deliveries are enabled (default: true) | -| `config` | JSON text | Type-specific configuration | -| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts | +| Field | Type | Description | +| ------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | UUID | Primary key | +| `webhook_id` | UUID | Foreign key → Webhook | +| `name` | string | Human-readable name | +| `type` | TargetType | One of: `http`, `slack`, `database`, `log` | +| `active` | boolean | Whether deliveries are enabled (default: true) | +| `url` | string | Where an `http` or `slack` target sends each event. It is a credential: the target list shows only its scheme and host, and only the target edit form shows it whole | +| `headers` | JSON text | An `http` target's extra request headers, a JSON object of name to value | +| `timeout_seconds` | integer | An `http` target's per-request timeout; 0 uses the shared 30-second client timeout | +| `forward_query` | boolean | Whether an `http` target is sent the query string each event arrived with | +| `archive_expiry` | integer | How long a `database` target keeps each archived event, as a Go `time.Duration` (nanoseconds); 0 keeps them forever | +| `archive_rotation` | string | How often a `database` target starts a new archive file: `none`, `monthly`, `daily` or `hourly` | +| `max_attempts` | integer | How many times an `http` or `slack` target attempts each delivery in all: at least 1, and 1 by default. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts | **Relations:** Belongs to Webhook. Has many Deliveries. **Target types:** -- **`http`** — Forward the event as an HTTP POST to a configured URL. - `max_retries` is the total number of delivery attempts, not retries on top of - the first: when `max_retries` is 0 (the default), the target operates in - fire-and-forget mode, a single attempt with no retries and no circuit breaker; - a value of N makes up to N attempts in all, retrying failed deliveries with - exponential backoff and protecting them with a per-target circuit breaker. +- **`http`** — Forward the event as an HTTP POST to a configured URL. With + `max_attempts` at 1 the target makes a single attempt; above 1 it retries a + failed delivery with exponential backoff, up to that many attempts in all, + behind a per-target circuit breaker. - **`slack`** — Post the event as a formatted message to a Slack-compatible - incoming webhook URL (`webhookUrl` in `config`). It is built on the same HTTP - core as `http` and honours `max_retries` identically, circuit breaker - included. See the Slack target section under "Per-Webhook Event Databases" for - the message format. + incoming webhook URL, its `url`. It is built on the same HTTP core as `http` + and honours `max_attempts` identically, circuit breaker included. See the + Slack target section under "Per-Webhook Event Databases" for the message + format. - **`database`** — Archive the full event as a row into the target's own archive database (`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for long-term retention, with an optional creation-validated expiry (default: keep @@ -1606,29 +1622,28 @@ should be forwarded. - **`log`** — Write the event to the application log (stdout). Useful for debugging. -The `config` field stores type-specific configuration as JSON (e.g., destination -URL, custom headers, timeout settings). +Each type uses only its own settings, and the ones it does not use stay empty: +`http` uses `url`, `headers`, `timeout_seconds` and `forward_query`; `slack` +uses `url`; `database` uses `archive_expiry` and `archive_rotation`; `log` uses +none. The add target form, the new webhook form and the target edit form check +every setting before it is saved, and each setting is a column of its own, so +nothing that reads a target parses its settings or has to handle one it cannot +read. -**`http` target configuration:** +An `http` target POSTs the event to its `url`. Its `headers` are applied last, +so they win over the event's own forwarded headers. -| Key | Type | Description | -| -------------- | ------------- | ----------------------------------------------------------------------------------------- | -| `url` | string | Destination the event is POSTed to | -| `headers` | object | Extra request headers, applied last so they win over the event's own forwarded headers | -| `timeout` | integer (sec) | Per-target request timeout; unset (or 0) uses the shared 30-second client timeout | -| `forwardQuery` | boolean | Pass the query string each event arrived with on to the target; unset (or false) does not | - -`forwardQuery` is off by default, and the target URL is then sent exactly as +`forward_query` is off by default, and the target URL is then sent exactly as configured. On, each delivery appends the event's query string to the target URL, joined with `&` when the URL already has a query string of its own; a replayed delivery and a resubmitted event's deliveries do the same. Both target forms offer it as "Pass the query string on to this target", and the target list shows it when it is on. -`timeout` is capped at **300 seconds**, and the form rejects anything above it -rather than substituting the cap. A delivery attempt holds one of the bounded -pool's workers for its whole duration, so an unbounded timeout would let a -single unresponsive destination stall the queue. +`timeout_seconds` is capped at **300 seconds**, and the form rejects anything +above it rather than substituting the cap. A delivery attempt holds one of the +bounded pool's workers for its whole duration, so an unbounded timeout would let +a single unresponsive destination stall the queue. `headers` rejects the names the delivery path or `net/http` writes regardless of what is configured: `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, @@ -1702,7 +1717,7 @@ The event log and the event's own page show the query string with the rest of the request. The event log leaves out one larger than 32 KiB, as it does request headers, and links to the event's page, which shows it whole. The `database` and `log` targets carry it with the rest of the event. An `http` target receives it -only when its `forwardQuery` setting is on. +only when its `forward_query` setting is on. #### Delivery @@ -1768,7 +1783,7 @@ is what bounds it. #### DeliveryResult The result of a single delivery attempt. Every attempt (including retries) is -individually logged for full observability. +individually logged, except one cut off by a stop (see Shutdown). | Field | Type | Description | | --------------- | ------- | ----------------------------------------- | @@ -1854,15 +1869,15 @@ These indexes on the per-webhook event databases are created by `internal/db/migrations/events/001_schema.sql`, and declared in the model tags to match: -| Table | Columns | Serves | -| ------------------ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, the webhook page's statistics and target list and the webhook list, which count each target's deliveries by status and when they finished, and the event log, which lists and counts the events with a failed delivery or one pending or retrying | -| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events | -| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events | -| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events | -| `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page | -| `events` | `entrypoint_id`, `deleted_at`, `resubmitted_from_id`, `created_at` | The webhook page's entrypoint list, which counts the events that arrived on each entrypoint's URL within the retention period | -| `events` | `created_at` | Retention, which selects expired events by age | +| Table | Columns | Serves | +| ------------------ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Delivery recovery at start and every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, the webhook page's statistics and target list and the webhook list, which count each target's deliveries by status and when they finished, and the event log, which lists and counts the events with a failed delivery or one pending or retrying | +| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events | +| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events | +| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events | +| `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page | +| `events` | `entrypoint_id`, `deleted_at`, `resubmitted_from_id`, `created_at` | The webhook page's entrypoint list, which counts the events that arrived on each entrypoint's URL within the retention period | +| `events` | `created_at` | Retention, which selects expired events by age | GORM's soft delete adds `deleted_at IS NULL` to these queries; retention leaves it out. SQLite keeps no statistics on these tables, and without them it rates @@ -1981,11 +1996,10 @@ target UUID keeps the file name unique. A webhook named `Orders (EU)` with a target named `Long-term archive` archives into `archive-orders-eu-long-term-archive-{target_uuid}.db`. -An optional `rotation` in the target's config JSON (e.g. `{"rotation":"daily"}`) -is `none`, the default, which keeps the one file, or `monthly`, `daily` or -`hourly`. A target that rotates writes each event to a file named for the period -of the event's receive time, in UTC, put before the `.db`: -`archive-orders-eu-long-term-archive-{target_uuid}-2026-10.db` monthly, +A target's `archive_rotation` is `none`, the default, which keeps the one file, +or `monthly`, `daily` or `hourly`. A target that rotates writes each event to a +file named for the period of the event's receive time, in UTC, put before the +`.db`: `archive-orders-eu-long-term-archive-{target_uuid}-2026-10.db` monthly, `…-2026-10-01.db` daily and `…-2026-10-01-19.db` hourly. Each file holds exactly its period's events, and the first event of a new period starts the next file, so a finished period's file can be moved away like any archive. A changed @@ -2020,12 +2034,11 @@ archiving without stopping the service; a moved or removed archive file is recreated automatically on the next write, from the SQL files in `internal/db/migrations/archive/`. A zero-length archive file is written to as a new archive: SQLite opens it as an empty database, so it holds nothing to lose. -An optional `expiry` in the target's config JSON (e.g. `{"expiry":"720h"}`) is -validated when the target is created — the default (unset or the literal -`never`) keeps rows forever — and rows older than the expiry are pruned each -time the archive is (re)opened. An archive write failure is never silent -success: the delivery records a failed attempt with the error and is marked -failed. +A target's `archive_expiry` is checked by the form that saves it — the default, +`never` on the forms and 0 in the column, keeps rows forever — and rows older +than the expiry are pruned each time the archive is (re)opened. An archive write +failure is never silent success: the delivery records a failed attempt with the +error and is marked failed. Because reopens only happen on writes, an archive whose target has stopped receiving events would never be pruned. A background **archive sweeper** closes @@ -2036,8 +2049,7 @@ traffic. The sweep never creates an archive — a target whose archive file does not yet exist is skipped, not initialised — it takes the same per-target lock the write path uses, so it can never interleave with a write, and it leaves the archive closed afterwards so the move-the-file-away workflow keeps working. -Archives with no expiry, or the expiry `never`, are not touched by the sweep at -all. +Archives with the expiry `never` are not touched by the sweep at all. For a target with several files, the write path prunes only the file it writes to, and the sweep prunes every one of them, taking the target's lock for one @@ -2089,15 +2101,15 @@ writes go on meanwhile, since under WAL a reader never blocks a writer. Until the open file is closed its `-wal` cannot be checkpointed past what the download reads, so a long download lets that `-wal` grow. -Deleting a webhook releases its archives: the delivery engine's cached archive -writers are dropped and their file handles closed, so nothing lingers after the -webhook is gone. The archive **files themselves are deliberately left on disk**. -Unlike the event database — per-webhook working storage that is hard-deleted -with the webhook — an archive is long-term storage an operator may still want to -keep or move away for offline retention, and destroying it as a side effect of -deleting a webhook would be unrecoverable. Removing an `archive-….db` is the -operator's call. Deleting a `database` target releases its writer the same way, -and for the same reason leaves its file alone. +Deleting a webhook releases its archives: its cached archive writers are dropped +and their file handles closed, so nothing lingers after the webhook is gone. The +archive **files themselves are deliberately left on disk**. Unlike the event +database — per-webhook working storage that is hard-deleted with the webhook — +an archive is long-term storage an operator may still want to keep or move away +for offline retention, and destroying it as a side effect of deleting a webhook +would be unrecoverable. Removing an `archive-….db` is the operator's call. +Deleting a `database` target releases its writer the same way, and for the same +reason leaves its file alone. The **Slack target type** sends webhook events as formatted messages to any Slack-compatible incoming webhook URL (works with Slack, Mattermost, and other @@ -2105,14 +2117,11 @@ compatible services). Each message includes event metadata (HTTP method, content type, timestamp, body size) and the payload pretty-printed in a code block. JSON payloads are automatically formatted with indentation for readability; non-JSON payloads are shown as raw text. Large payloads are truncated to keep messages -reasonable. Config stores `webhookUrl` — the Slack/Mattermost incoming webhook -endpoint. That is the JSON key; the error text for a missing one reads -`webhook_url is required`, which is the message, not the key. +reasonable. The target's `url` is the Slack/Mattermost incoming webhook +endpoint. The database uses the -[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver at runtime, -though CGO is required at build time due to the transitive `mattn/go-sqlite3` -dependency from `gorm.io/driver/sqlite`. +[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver. #### Schema @@ -2144,12 +2153,8 @@ External Service 1. Look up Entrypoint by UUID — 404 if unknown, 410 if inactive 2. Read the body under the 1 MB cap - 3. Capture full request as Event - 4. Create Delivery records for each active Target - 5. Build self-contained delivery.Task structs - (target config + event data inline for - bodies < 16 KiB) - 6. Notify Engine via channel (no DB read needed) + 3. Hand the full request, as an Event, to + Engine.StoreEvent │ ▼ ┌──────────────┐ @@ -2158,19 +2163,32 @@ External Service │ (worker │ │ pool) │ └──────┬───────┘ + 4. Store the Event, a pending Delivery for each + active Target, and the totals, in one + transaction + 5. Build a delivery.Task for each Delivery + (target settings + event data, the body + inline when it is under 16 KiB) + 6. Queue the tasks on the delivery channel │ ┌── bounded worker pool (N workers) ──┐ ▼ ▼ ▼ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ HTTP Target│ │ HTTP Target│ │ Log Target │ - │(max_retries│ │(max_retries│ │ (stdout) │ - │ == 0) │ │ > 0, │ └────────────┘ - │ fire+forget│ │ backoff + │ + │max_attempts│ │max_attempts│ │ (stdout) │ + │ == 1, │ │ > 1, │ └────────────┘ + │ one attempt│ │ backoff + │ └────────────┘ │ circuit │ - │ breaker) │ + │ breaker │ └────────────┘ ``` +A **Resubmit** stores its copy of an event through the same `Engine.StoreEvent`, +and a **Replay** adds one delivery of a stored event through +`Engine.CreateReplay`, which writes the delivery and its target's totals in one +transaction and queues it. Those two methods are the only code that creates a +delivery; the handlers read and check the request, then make the one call. + ### Bounded Worker Pool The delivery engine uses a **fixed-size worker pool** (default: 10 workers) to @@ -2180,8 +2198,9 @@ preventing goroutine explosions regardless of queue depth. **Architecture:** - **Channels as queues:** Two buffered channels serve as bounded queues: a - delivery channel (new tasks from the webhook handler) and a retry channel - (tasks from backoff timers). Both are buffered to 10,000. + delivery channel (the tasks of newly stored deliveries and of the `pending` + deliveries the recovery routine hands back) and a retry channel (tasks from + backoff timers). Both are buffered to 10,000. - **Fan-out via channel, not goroutines:** When an event arrives with multiple targets, each `delivery.Task` is sent to the delivery channel. Workers pick them up and process them — no goroutine-per-target. @@ -2190,7 +2209,7 @@ preventing goroutine explosions regardless of queue depth. Workers are the ONLY goroutines doing actual HTTP delivery. - **Retry backpressure with DB fallback:** When a retry timer fires and the retry channel is full, the timer is dropped — the delivery stays in `retrying` - status in the database. A periodic sweep (every 60s) scans for these + status in the database. The sweep (every 60s, see Recovery below) finds these "orphaned" retries and re-queues them. No blocked goroutines, no unbounded timer chains. - **Bounded concurrency:** At most N deliveries (N = number of workers) are @@ -2206,31 +2225,47 @@ This means: buffer rather than spawning more goroutines. - **Independent results** — each worker records its own delivery result in the per-webhook database without coordination. -- **Graceful shutdown** — cancel the context, workers finish their current task - and exit. The stop hook waits for the pool via `lifecycle.WaitForShutdown`, - which bounds that wait by fx's stop timeout rather than blocking forever on a - wedged worker. On timeout it logs at `ERROR` and returns an error, and the - goroutines that did not finish are still running — an unclean shutdown is - reported rather than hidden. +- **Graceful shutdown** — the stop hook cancels the workers' context. A worker + may still take tasks already queued before it exits. An `http` or `slack` + request in flight is cut off, and one started after the cancel fails at once. + Neither is recorded or counted in the metrics, and the next start sends the + delivery again (see Shutdown). A `database` or `log` task runs as normal. The + stop hook waits for the pool via `lifecycle.WaitForShutdown`, which bounds + that wait by fx's stop timeout rather than blocking forever on a wedged + worker. On timeout it logs at `ERROR` and returns an error, and the goroutines + that did not finish are still running — an unclean shutdown is reported rather + than hidden. -**Recovery paths:** +**Recovery:** one routine finds the deliveries the engine is not working on and +hands them back. It runs once when the engine starts, and then every 60 seconds; +that periodic run is the sweep. For every webhook it reads the `pending` and +`retrying` deliveries. One that already has a successful attempt is marked +`delivered` and not sent again. A `retrying` delivery gets a backoff timer for +what is left of its backoff, and is queued at once if nothing is left. A +`pending` delivery is queued on the delivery channel. A delivery the engine has +queued, is attempting, or holds a backoff timer for is left alone, at start as +on every sweep. This is what catches "orphaned" retries, whose in-memory timer +was dropped because the retry channel was full, and deliveries a stop or a crash +cut off: the database is the durable fallback that ensures no delivery is +permanently lost, even under extreme backpressure. -1. **Startup recovery:** When the engine starts, it scans all per-webhook - databases for `pending` and `retrying` deliveries. Pending deliveries are - sent to the delivery channel; retrying deliveries get backoff timers - scheduled. -2. **Periodic retry sweep (DB-mediated fallback):** Every 60 seconds the engine - scans for `retrying` deliveries whose backoff period has elapsed. This - catches "orphaned" retries — ones whose in-memory timer was dropped because - the retry channel was full. The database is the durable fallback that ensures - no retry is permanently lost, even under extreme backpressure. +The run at start and the sweep differ in two ways: + +- At start every `pending` delivery is taken, however recent, so one a stop cut + off moments ago is sent again at once (see Shutdown). The sweep takes only + `pending` deliveries untouched for 15 minutes, at most 500 per webhook each + time, so a delivery whose outcome the database will not record is sent again + every 15 minutes rather than every minute. +- At start every webhook's database is opened, so that a missing one is reported + then (see Per-Webhook Event Databases). The sweep skips a webhook whose + database file is missing rather than create it. **Changing a target's type does not migrate in-flight deliveries.** Only `http` and `slack` targets own durable retries; `database` and `log` targets are fire-and-forget and never produce a `retrying` delivery. If a target's `type` is edited from a retrying type to a non-retrying (or unknown) one while one of its -deliveries is still `retrying`, both recovery paths above terminally mark that -delivery `failed` and record a `DeliveryResult` naming the current target type +deliveries is still `retrying`, the recovery routine above terminally marks that +delivery `failed` and records a `DeliveryResult` naming the current target type as the reason, logging it at warn level. The delivery is not re-dispatched under the new type — the operator never asked for that delivery — and the event itself remains stored in the per-webhook event database. Nothing is lost: the delivery @@ -2240,11 +2275,11 @@ re-inject it to every currently active target. ### Circuit Breaker (HTTP and Slack Targets with Retries) -`http` and `slack` targets with `max_retries` > 0 are protected by a +`http` and `slack` targets with `max_attempts` > 1 are protected by a **per-target circuit breaker** that prevents hammering a down target with repeated failed delivery attempts. The circuit breaker is in-memory only and -resets on restart (which is fine — startup recovery rescans the database -anyway). +resets on restart (which is fine — the recovery routine rescans the database at +start anyway). **States:** @@ -2281,10 +2316,10 @@ anyway). - **Cooldown:** 30 seconds in open state before probing **Scope:** Circuit breakers apply to **`http` and `slack` targets with -`max_retries` > 0**. The Slack target is built on the same HTTP core and hands -its own `max_retries` to the same retry path, so it gets a breaker with the same -5-failure / 30-second defaults. Fire-and-forget targets of either type -(`max_retries` == 0), database targets (local operations), and log targets +`max_attempts` > 1**. The Slack target is built on the same HTTP core and hands +its own `max_attempts` to the same retry path, so it gets a breaker with the +same 5-failure / 30-second defaults. Single-attempt targets of either type +(`max_attempts` == 1), database targets (local operations), and log targets (stdout) do not use circuit breakers. When a circuit is open and a new delivery arrives, the engine marks the delivery @@ -2317,7 +2352,7 @@ getting anywhere. | Metric | Type | Meaning | | -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `webhooker_events_received_total` | counter | Events received and durably stored. Compare against the delivery counters on one dashboard | -| `webhooker_delivery_attempts_total` | counter | Delivery attempts actually dispatched to a target. A delivery a circuit breaker refused is not one: it is counted as a retry instead, but only when the refusal moves it into `retrying` | +| `webhooker_delivery_attempts_total` | counter | Delivery attempts actually dispatched to a target. A delivery a circuit breaker refused is not one: it is counted as a retry instead, but only when the refusal moves it into `retrying`. An attempt cut off by a stop is not counted (see Shutdown) | | `webhooker_deliveries_succeeded_total` | counter | Deliveries that reached `delivered` | | `webhooker_deliveries_failed_total` | counter | Deliveries that failed terminally and will not be retried | | `webhooker_delivery_retries_total` | counter | Deliveries put back into `retrying` | @@ -2416,27 +2451,32 @@ log volume it caps rather than eliminates. A path that names no entrypoint is recorded by the handler at `DEBUG`, and the aggregate limiter logs its own rejections at `DEBUG` and without the path, so neither appears at all under the default level. The per-entrypoint limiter is the loud one: it logs every -rejection at `WARN` with the request path, which on this route is -attacker-controlled text. A client hammering a single invented path is served +rejection at `WARN`, naming the route pattern `/h/{uuid}` rather than the +request path. A client hammering a single invented path is served `RECEIVER_RATE_LIMIT` requests and has the rest of its aggregate budget rejected there, so the aggregate limit is what bounds the _number_ of those `WARN` lines — to under ten times `RECEIVER_RATE_LIMIT` per minute per client IP, 1080 at the -defaults, where before it there was no bound at all. Their _width_ is bounded by -the field budgets below, the same ones the access log spends. The access log is -bounded by neither limit: every request is recorded once at `INFO`, served or -rejected alike. +defaults, where before it there was no bound at all. The access log is bounded +by neither limit: every request is recorded once at `INFO`, served or rejected +alike. -What the access log does bound is the _content_ of those lines. A 3xx or 4xx -response logs the chi route pattern — `/h/{uuid}`, `/user/{username}//`, or the -literal `(unmatched)` when the request hit no route at all — in place of the -concrete URL. Those are the outcomes an unauthenticated client can drive for -free: 404 and 429 on any invented receiver path, a login redirect on any -invented profile path. Logging the URL there would let a flood write text of its -own choosing, at a length of its own choosing, into the log. 2xx and 5xx -responses keep the concrete path — a success resolved against a static route or -against the operator's own data (on the receiver, against a stored entrypoint -UUID), and a 5xx is a bug in this service, where the exact path is the evidence -and no client can provoke one at will. +What the access log does bound is the _content_ of those lines. On the receiver +route it logs the route pattern `/h/{uuid}` whatever the status, because the +concrete path carries the entrypoint UUID, which is the entrypoint's credential +(see +[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret)). +A request chi never routed, because it matched no route or because a middleware +answered it first, logs the literal `(unmatched)` whatever the status. In the +`dev` environment the CORS middleware answers a preflight that way, so a +preflight to the receiver does not log its UUID either. On every other route, a +3xx or 4xx response logs the chi route pattern, such as `/user/{username}//`, in +place of the concrete URL. Those are the outcomes an unauthenticated client can +drive for free: a 404 on any invented path, a login redirect on any invented +profile path. Logging the URL there would let a flood write text of its own +choosing, at a length of its own choosing, into the log. 2xx and 5xx responses +keep the concrete path — a success resolved against a static route or against +the operator's own data, and a 5xx is a bug in this service, where the exact +path is the evidence and no client can provoke one at will. The query string is never logged; it is replaced by the fixed marker `?(redacted)`. It is client-chosen on every route, and @@ -2448,6 +2488,11 @@ to, `notice`, which names the line a page shows after an action, and the event log's `show`, which picks the events it lists. A query string sent to an entrypoint is not lost either: the event stores it, and the event log shows it. +The `referer` field keeps only the scheme and host of the request's `Referer`, +and logs `(redacted)` for one without them, because its path can be an +entrypoint URL: a sender that follows the `http://` to `https://` redirect in +the nginx example sends the URL it was redirected from. + Client-supplied request content does not leave the host by the other route either. The Sentry SDK attaches the request to every event it captures, independently of the access log, and `SendDefaultPII=false` does not cover all @@ -2457,14 +2502,18 @@ are both taken unconditionally, the body precisely because these handlers call body with `(redacted)`, drops cookies and the remote-address environment, and reduces the headers to a fixed allowlist — `Accept`, `Content-Length`, `Content-Type`, `Host`, `Origin`, `Referer`, `User-Agent` and `X-Request-Id`. +`Referer` is sent as its scheme and host only, because its path can be an +entrypoint URL; `internal/referer.SchemeAndHost` does that reduction for both +the access log's `referer` field and this hook. The same hook rewrites the request URL. The SDK builds it as `scheme://host/path` from the concrete path, which on the receiver route is `/h/` in full — and that UUID is a write capability, not an identifier: anyone holding it can post events this service accepts and its targets then -deliver. A tracker has its own retention, access control and deletion policy, so -the rule the access log follows above does not carry across that boundary. What -is sent is the chi route pattern instead: `http://host/h/{uuid}`. +deliver. What is sent is the chi route pattern instead: `http://host/h/{uuid}`. +A tracker has its own retention, access control and deletion policy, so the +pattern is sent on every route, including the 2xx and 5xx responses outside the +receiver for which the access log keeps the concrete path. The scheme and the host are kept, and everything else in the URL is discarded rather than edited, so a future SDK version that starts appending a query string @@ -2526,8 +2575,9 @@ receiver route. What survives still names the failing route — scheme, host, route pattern, method — and `X-Request-Id` ties the event to the local access log line that holds the rest. Nothing dropped is needed for the likeliest use, debugging a CSRF rejection. Its three inputs are the TLS decision, `Origin` and -`Referer`; the latter two are kept, and the first is the scheme of the retained -URL, because the SDK derives that scheme from +`Referer`; the latter two are kept (`Referer` as its scheme and host, which is +all the check compares), and the first is the scheme of the retained URL, +because the SDK derives that scheme from `r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"`. That predicate is the SDK's own and is stricter than `internal/reqtls.IsTLS`, which this service now uses everywhere it decides transport: the SDK reports `http` for the @@ -2593,9 +2643,7 @@ than it: | ------------------------------------------ | ------- | ------------------- | ------------------------------------------ | | `request body exceeds limit` (413) | `WARN` | path, method | yes — `MaxBodySize` precedes `RequireAuth` | | `csrf: token validation failed` (403) | `WARN` | path, method | yes — `CSRF` precedes `RequireAuth` | -| `... rate limit exceeded` (429) | `WARN` | path | yes, on the receiver | | `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition | -| `entrypoint not found` | `DEBUG` | entrypoint UUID | yes, on the receiver | | `user not found` / `invalid password` | `DEBUG` | username | yes, on the sign-in form | | `login failure limit exceeded` (429) | `WARN` | path | yes, on the sign-in form | | `password verification capacity exhausted` | `WARN` | path | yes, on the sign-in form | @@ -2623,13 +2671,12 @@ wider of the two handlers spends more on than the client spent sending them. Every case holds each line to the 2,560-byte ceiling. That per-line ceiling is what the figure above states, and every row establishes it. -Three of the sites go further and bound the whole flood's output — the total -bytes a run of distinct invented values wrote, which is the shape an operator -sizing storage cares about. They are `request body exceeds limit` -(`TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog`), -`entrypoint not found` and `user not found` (the last two through -`assertBoundedFlood`). The other rows carry no aggregate assertion; the per-line -ceiling is what they establish. +Two of the sites go further and bound the whole flood's output — the total bytes +a run of distinct invented values wrote, which is the shape an operator sizing +storage cares about. They are `request body exceeds limit` +(`TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog`) and `user not found` +(through `assertBoundedFlood`). The other rows carry no aggregate assertion; the +per-line ceiling is what they establish. `internal/logfield/logfield_test.go` measures the per-rune charge against what the handlers really emit, over roughly 3,000 code points on each, so an @@ -2647,7 +2694,7 @@ everything else, so its lines take the level the operator set and the handler `internal/logger` selected, and every value it emits is spent through the same `internal/logfield` budget. A record-not-found is not logged as an error: it is the expected outcome on both of those paths, and each handler already records -its own miss at `DEBUG` — bounded, per the table above — without the SQL. Slow +its own miss at `DEBUG`, without the SQL and within the ceiling above. Slow statements are kept, at `WARN`, above the same 200 ms threshold GORM used and with the statement bounded, because that report is the one thing GORM's logger gave an operator that nothing else here does. The adapter orders its cases @@ -2972,6 +3019,12 @@ webhooker/ ├── cmd/webhooker/ │ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx ├── internal/ +│ ├── archive/ +│ │ ├── archive.go # The archive writer of each database target, eviction, rename, and the rename lock +│ │ ├── writer.go # Archive file lifecycle and pruning +│ │ ├── rotation.go # Archive rotation and file names +│ │ ├── download.go # Archive download as gzipped JSON +│ │ └── sweeper.go # Periodic pruning of idle archives │ ├── banner/ │ │ └── banner.go # Ruled block for the one credential shown in the clear │ ├── resetpw/ @@ -3011,24 +3064,21 @@ webhooker/ │ │ └── logfield.go # Encoded-byte budget for client-supplied log values │ ├── delivery/ │ │ ├── engine.go # Event-driven delivery engine (channel + timer based) +│ │ ├── store.go # Storing an event with its deliveries, and a replay, then queueing them │ │ ├── circuit_breaker.go # Per-target circuit breaker for http/slack targets with retries │ │ ├── target.go # Target interface, Task, Scheduler │ │ ├── target_http.go # HTTP target (retries, circuit breaker) │ │ ├── target_slack.go # Slack/Mattermost incoming-webhook target -│ │ ├── target_database.go # Database archive target -│ │ ├── target_database_archive.go # Archive file lifecycle and pruning -│ │ ├── target_database_rotation.go # Archive rotation and file names -│ │ ├── target_database_export.go # Archive download as gzipped JSON +│ │ ├── target_database.go # Database archive target, writing through internal/archive │ │ ├── target_log.go # Log target (stdout) -│ │ ├── target_config_view.go # Masked target config for templates -│ │ ├── archive_sweeper.go # Periodic pruning of idle archives +│ │ ├── target_config_view.go # Masked target settings for templates │ │ ├── queue_depth.go # Periodic sampler behind the queue-depth gauges │ │ ├── url_mask.go # Strips credentials from *url.Error │ │ └── ssrf.go # SSRF prevention (IP validation, safe HTTP transport) │ ├── handlers/ │ │ ├── handlers.go # Base handler struct, JSON helpers, template rendering │ │ ├── auth.go # Login, logout handlers -│ │ ├── delivery_replay.go # Per-delivery replay: new delivery, current target config +│ │ ├── delivery_replay.go # Per-delivery replay: new delivery, current target settings │ │ ├── event_resubmit.go # Event resubmit: new event, all currently active targets │ │ ├── entrypoint_view.go # Masked entrypoint view for templates │ │ ├── event_log_view.go # Event log projection, byte-capped in SQL @@ -3043,7 +3093,7 @@ webhooker/ │ │ ├── webhook_delete.go # Webhook delete, event database and archive writer removal │ │ ├── event_log.go # Event log page: loaders, filters, delivery views │ │ ├── entrypoint.go # Entrypoint create, edit, delete and toggle -│ │ ├── target_create.go # Target create, per-type config builders +│ │ ├── target_create.go # Target create, each type's settings from the form │ │ ├── target_delete.go # Target delete │ │ ├── target_toggle.go # Target toggle │ │ ├── shared.go # Helpers shared by several handlers @@ -3062,6 +3112,8 @@ webhooker/ │ │ ├── ratelimit.go # Per-IP rate limiting middleware (go-chi/httprate) │ │ ├── loginguard.go # Login failure counters and the Argon2id verification semaphore │ │ └── middlewaretest/ # Test support: a Middleware for tests in other packages +│ ├── referer/ +│ │ └── referer.go # SchemeAndHost: cuts a Referer down to its scheme and host, for the access log and the Sentry hook │ ├── reqtls/ │ │ └── reqtls.go # IsTLS: the one TLS predicate, r.TLS or X-Forwarded-Proto │ ├── server/ @@ -3076,8 +3128,8 @@ webhooker/ │ ├── static.go # //go:embed directive │ ├── css/input.css # Tailwind input, source for tailwind.css (make css) │ ├── css/tailwind.css # Generated stylesheet the pages load -│ ├── css/style.css # Hand-written, loaded after tailwind.css: btn-small, the pointer cursor for input.css's buttons, the webhook list cards' focus outline -│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components +│ ├── css/style.css # Hand-written, loaded after tailwind.css: btn-small, the pointer cursor for input.css's buttons, the webhook list cards' focus outline, the rule hiding x-cloak elements +│ ├── js/app.js # Copy-to-clipboard, the delete prompts, and the Alpine.js components │ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed ├── templates/ # Go HTML templates (base, login, sources, etc.) ├── script/ # Scripts to Rule Them All entrypoints @@ -3109,21 +3161,26 @@ Components are wired via Uber fx in this order: 10. `metrics.NewRegistry` — The registry `/metrics` serves 11. `metrics.New` — The delivery collectors, registered on that registry 12. `middleware.New` — HTTP middleware -13. `delivery.New` — Event-driven delivery engine -14. `delivery.NewArchiveSweeper` — Periodic pruning of idle archives -15. `delivery.Engine` → `delivery.Notifier` — interface bridge -16. `delivery.Engine` → `delivery.Archives` — interface bridge so deleting or +13. `delivery.New` — Event-driven delivery engine, which also stores each event + with its deliveries and creates replay deliveries +14. `archive.NewWriters` — The database targets' archive writers, and the lock + every rename of their files runs under +15. `archive.NewSweeper` — Periodic pruning of idle archives +16. `archive.Writers` → `archive.Archives` — interface bridge so deleting or renaming a webhook or target reaches its archive files -17. `server.New` — HTTP server and router +17. `delivery.Engine` → `delivery.CircuitBreakers` — interface bridge so the + pages can show a target whose deliveries are paused +18. `server.New` — HTTP server and router The server starts via -`fx.Invoke(func(*server.Server, *delivery.Engine, *database.RetentionReaper, *delivery.ArchiveSweeper) {})`, -which triggers the fx lifecycle hooks in dependency order. The -`delivery.Notifier` interface allows the webhook handler to send self-contained -`delivery.Task` slices to the engine without a direct package dependency. Each -task carries all target config and event data inline (for bodies under 16 KiB, -`delivery.MaxInlineBodySize`), so the engine can deliver without reading from -any database — it only writes to record results. +`fx.Invoke(func(*server.Server, *delivery.Engine, *database.RetentionReaper, *archive.Sweeper) {})`, +which triggers the fx lifecycle hooks in dependency order. The handlers take +`*delivery.Engine` itself: the receiver and resubmit handlers store an event +through `StoreEvent`, and the replay handler adds a delivery through +`CreateReplay`. Each task the engine queues carries the target's settings and +the event's data, and the body too when it is under 16 KiB +(`delivery.MaxInlineBodySize`); a larger body is read from the event row when +the attempt runs. ### Middleware Stack @@ -3131,8 +3188,9 @@ Applied to all routes in this order: 1. **RequestID** — Generate unique request IDs (chi built-in) 2. **SecurityHeaders** — Production security headers on every response (HSTS - unless `HSTS_MAX_AGE` is `0`, X-Content-Type-Options, X-Frame-Options, CSP, - Referrer-Policy, Permissions-Policy) + unless `HSTS_MAX_AGE` is `0`, X-Content-Type-Options, X-Frame-Options, CSP + `default-src 'self'; script-src 'self'; style-src 'self'`, Referrer-Policy, + Permissions-Policy) 3. **Logging** — Structured request logging (method, URL, status, latency, remote IP, client IP, user agent, request ID) 4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and @@ -3217,6 +3275,12 @@ credential check, see [The login endpoint](#the-login-endpoint). - Production security headers on all responses: HSTS (unless `HSTS_MAX_AGE` is `0`), X-Content-Type-Options (`nosniff`), X-Frame-Options (`DENY`), Content-Security-Policy, Referrer-Policy, and Permissions-Policy +- The Content-Security-Policy is + `default-src 'self'; script-src 'self'; style-src 'self'`: a page takes + scripts and styles only from files webhooker serves, and the browser runs no + inline script or event handler and ignores any ` {{block "head" .}}{{end}} {{end}} diff --git a/templates/settings.html b/templates/settings.html index e78cad3..4c66162 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -13,13 +13,11 @@
+ between its entries. wrap-anywhere breaks inside a + value only when it alone is wider than the line. -->
{{.Name}} - {{.Value}} + {{.Value}}

{{.Description}}

diff --git a/templates/source_detail.html b/templates/source_detail.html index 7e19e35..a47c5df 100644 --- a/templates/source_detail.html +++ b/templates/source_detail.html @@ -5,12 +5,10 @@ {{define "content"}} -
+ one line. wrap-anywhere goes only on names and descriptions: a + row too wide for a phone must still run past the edge, where the + browser test sees it, rather than break its controls mid-word. --> +
← Back to webhooks
@@ -23,11 +21,12 @@
Full Event Log Edit - -
+ +
@@ -90,7 +89,7 @@ {{if .Active}}Deactivate{{else}}Activate{{end}} -
+
@@ -136,7 +135,7 @@ data-headers="{{.TargetForm.Headers}}" data-timeout="{{.TargetForm.Timeout}}" {{if .TargetForm.ForwardQuery}}data-forward-query{{end}} - data-max-retries="{{.TargetForm.MaxRetries}}" + data-max-attempts="{{.TargetForm.MaxAttempts}}" data-expiry="{{.TargetForm.Expiry}}" data-rotation="{{.TargetForm.Rotation}}">
@@ -194,9 +193,9 @@
- +
-

How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.

+

How many times each delivery is attempted in all, the first attempt included.

@@ -210,9 +209,9 @@
- +
-

How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.

+

How many times each delivery is attempted in all, the first attempt included.

@@ -278,7 +277,7 @@ {{if .Active}}Deactivate{{else}}Activate{{end}} -
+
diff --git a/templates/target_edit.html b/templates/target_edit.html index 42f6d79..82b159b 100644 --- a/templates/target_edit.html +++ b/templates/target_edit.html @@ -89,9 +89,9 @@ {{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
- - -

How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.

+ + +

How many times each delivery is attempted in all, the first attempt included.

{{end}}