Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fedb20871 |
No files matched your search
+9
-15
@@ -214,22 +214,16 @@ RUN version="$(make version VERSION="$VERSION")"; \
|
|||||||
# (Alpine.js, extracted from its tarball in 3p/) first.
|
# (Alpine.js, extracted from its tarball in 3p/) first.
|
||||||
RUN make build VERSION="$VERSION"
|
RUN make build VERSION="$VERSION"
|
||||||
|
|
||||||
# Rebuild with static linking for the Alpine runtime. The cgo binary the
|
# Rebuild with static linking for Alpine runtime.
|
||||||
# step above builds is dynamically linked against glibc, which Alpine
|
# make build already verified compilation.
|
||||||
# (musl) does not have.
|
# The CGO binary from `make build` is dynamically linked against glibc,
|
||||||
|
# which doesn't exist on Alpine (musl). Rebuild with static linking so
|
||||||
|
# the binary runs on Alpine without glibc.
|
||||||
#
|
#
|
||||||
# A statically linked glibc still loads shared libraries at run time for
|
# The static flags go in through GO_LDFLAGS rather than a -ldflags of
|
||||||
# getaddrinfo and dlopen, and Alpine has none to load, so the build tags
|
# their own: the build target composes them with the -X that stamps the
|
||||||
# keep both out of the binary: netgo and osusergo select Go's own DNS
|
# version, so this relink cannot silently drop the stamp.
|
||||||
# resolver and user lookup instead of glibc's, and
|
RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'
|
||||||
# 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
|
# Runtime stage
|
||||||
# alpine:3.21, 2026-03-17
|
# alpine:3.21, 2026-03-17
|
||||||
|
|||||||
@@ -16,16 +16,14 @@ VERSION ?= $(shell script/version)
|
|||||||
# exists to correct.
|
# exists to correct.
|
||||||
override VERSION := $(or $(strip $(VERSION)),$(shell script/version))
|
override VERSION := $(or $(strip $(VERSION)),$(shell script/version))
|
||||||
|
|
||||||
# Extra linker flags and build tags for the build target. The static
|
# Extra linker flags for the build target. The static relink in the
|
||||||
# relink in the Dockerfile passes -extldflags and its build tags through
|
# Dockerfile adds -extldflags here rather than passing its own -ldflags,
|
||||||
# these rather than a -ldflags of its own, so composing flags cannot drop
|
# so composing flags cannot drop the version stamp.
|
||||||
# the version stamp.
|
|
||||||
#
|
#
|
||||||
# The build target itself always passes -trimpath and -s -w, as the Go
|
# 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
|
# Dockerfile in REPO_POLICIES.md does: no build paths, symbol table or
|
||||||
# debug information in the binary.
|
# debug information in the binary.
|
||||||
GO_LDFLAGS ?=
|
GO_LDFLAGS ?=
|
||||||
GO_TAGS ?=
|
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@script/bootstrap
|
@script/bootstrap
|
||||||
@@ -55,7 +53,7 @@ check:
|
|||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
build: assets
|
build: assets
|
||||||
go build -trimpath -tags '$(GO_TAGS)' -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
go build -trimpath -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
./bin/webhooker
|
./bin/webhooker
|
||||||
|
|||||||
@@ -465,7 +465,10 @@ idle timestamp is rewritten at most once per tenth of the idle window rather
|
|||||||
than on every request, which means a session may expire up to 10% early relative
|
than on every request, which means a session may expire up to 10% early relative
|
||||||
to the user's true last request, but never late.
|
to the user's true last request, but never late.
|
||||||
|
|
||||||
Both clocks are anchored by timestamps stored in the session cookie.
|
Both clocks are anchored by timestamps stored in the session cookie. Sessions
|
||||||
|
issued before this feature existed carry neither, so they are treated as
|
||||||
|
expired: upgrading to a build that has it logs every existing session out once,
|
||||||
|
and those users sign in again.
|
||||||
|
|
||||||
#### Invalid values abort startup
|
#### Invalid values abort startup
|
||||||
|
|
||||||
@@ -699,11 +702,11 @@ app as that user.
|
|||||||
`webhooker.db` holds target configuration in plaintext — bearer tokens, API
|
`webhooker.db` holds target configuration in plaintext — bearer tokens, API
|
||||||
keys, Slack webhook URLs — along with the session encryption key, so webhooker
|
keys, Slack webhook URLs — along with the session encryption key, so webhooker
|
||||||
creates every SQLite file it owns `0600`: each database and both of its `-wal`
|
creates every SQLite file it owns `0600`: each database and both of its `-wal`
|
||||||
and `-shm` sidecars, across all three tiers. Any of them found at another mode
|
and `-shm` sidecars, across all three tiers. Files an earlier build left `0644`
|
||||||
is set to `0600` when the database is opened. The directory's `0750` is defence
|
are tightened when they are opened. The directory's `0750` is defence in depth —
|
||||||
in depth — it stops other local users listing the directory and learning your
|
it stops other local users listing the directory and learning your webhook UUIDs
|
||||||
webhook UUIDs from the `events-{uuid}.db` filenames — not the barrier protecting
|
from the `events-{uuid}.db` filenames — not the barrier protecting the
|
||||||
the credentials.
|
credentials.
|
||||||
|
|
||||||
### Running under upaas
|
### Running under upaas
|
||||||
|
|
||||||
@@ -915,7 +918,7 @@ replaces the `max-age` it stored, so to end the policy sooner, set a short
|
|||||||
`HSTS_MAX_AGE` such as `1s` and keep it until returning browsers have picked it
|
`HSTS_MAX_AGE` such as `1s` and keep it until returning browsers have picked it
|
||||||
up, or have a reverse proxy send `max-age=0`.
|
up, or have a reverse proxy send `max-age=0`.
|
||||||
|
|
||||||
## Backup and Restore
|
## Backup, Restore, and Upgrades
|
||||||
|
|
||||||
### What to back up
|
### What to back up
|
||||||
|
|
||||||
@@ -1068,14 +1071,60 @@ beside it, or wait until there are none.
|
|||||||
|
|
||||||
4. Start the service. The container gives the directory and the restored files
|
4. Start the service. The container gives the directory and the restored files
|
||||||
to the `webhooker` user before the app starts, whoever restored them (see
|
to the `webhooker` user before the app starts, whoever restored them (see
|
||||||
[Running with Docker](#running-with-docker)).
|
[Running with Docker](#running-with-docker)). Each restored database gets any
|
||||||
|
of its SQL migrations it has not had as it is opened (see
|
||||||
|
[Upgrades](#upgrades)).
|
||||||
|
|
||||||
|
### Upgrades
|
||||||
|
|
||||||
|
**Back up before every upgrade.** Each kind of database file has its schema in
|
||||||
|
SQL files built into the binary, one directory per kind under
|
||||||
|
`internal/db/migrations/`. In each, `000_migration.sql` creates the
|
||||||
|
`schema_migrations` table, which records the number of every file the database
|
||||||
|
has had, and `001_schema.sql` holds the whole schema. Opening a database runs,
|
||||||
|
in one transaction, every file in its directory that `schema_migrations` does
|
||||||
|
not list:
|
||||||
|
|
||||||
|
- `main/` for the main database, on connect — settings, users, API keys,
|
||||||
|
webhooks, entrypoints and targets
|
||||||
|
- `events/` for each event database, when it is lazily opened — events,
|
||||||
|
deliveries, delivery results and their running totals
|
||||||
|
- `archive/` for each archive database, on every open and reopen
|
||||||
|
|
||||||
|
There are no down migrations.
|
||||||
|
|
||||||
|
Upgrade procedure:
|
||||||
|
|
||||||
|
1. Stop the service.
|
||||||
|
2. Back up `DATA_DIR` using one of the procedures above.
|
||||||
|
3. Pull the new image and start it.
|
||||||
|
4. Confirm `database migrations completed` in the logs before putting traffic
|
||||||
|
back on it.
|
||||||
|
5. Confirm the new build is the one running:
|
||||||
|
`curl -s http://host:8080/.well-known/healthcheck` reports the version it was
|
||||||
|
stamped with (see [Version stamping](#version-stamping)).
|
||||||
|
|
||||||
|
**Upgrading past the introduction of `BIND_ADDRESS`:** earlier versions always
|
||||||
|
bound every interface. **Container deployments are unaffected** — the image
|
||||||
|
ships `ENV BIND_ADDRESS=0.0.0.0`, so a `docker run` or Compose service that
|
||||||
|
worked before still works with nothing changed.
|
||||||
|
|
||||||
|
A **bare binary** is the case that changes: the listener now binds `127.0.0.1`
|
||||||
|
unless `BIND_ADDRESS` says otherwise, so a deployment that relied on reaching it
|
||||||
|
from another host becomes unreachable until it sets the address the proxy
|
||||||
|
connects to. Check the `bindAddress` field of the startup log to see what a
|
||||||
|
running process bound. See [Bind address](#bind-address).
|
||||||
|
|
||||||
|
**Downgrade is unsupported.** Once a newer binary has migrated the files there
|
||||||
|
is no way to move them back. The only supported way back to an older version is
|
||||||
|
restoring the pre-upgrade backup, which discards everything received since that
|
||||||
|
backup was taken.
|
||||||
|
|
||||||
### Version stamping
|
### Version stamping
|
||||||
|
|
||||||
The binary reports its version at `/.well-known/healthcheck` (the `version`
|
The binary reports its version at `/.well-known/healthcheck` (the `version`
|
||||||
field), in the UI footer, and in the startup log line (`msg=starting`,
|
field), in the UI footer, and in the startup log line (`msg=starting`,
|
||||||
`version=...`). It is also the Sentry release name, as `webhooker-{version}`.
|
`version=...`). It is also the Sentry release name, as `webhooker-{version}`.
|
||||||
`curl -s http://host:8080/.well-known/healthcheck` shows which build is running.
|
|
||||||
The value is stamped in at build time by the linker; it is not read from a file
|
The value is stamped in at build time by the linker; it is not read from a file
|
||||||
at runtime, so it identifies the build itself.
|
at runtime, so it identifies the build itself.
|
||||||
|
|
||||||
@@ -1534,7 +1583,8 @@ 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
|
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
|
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
|
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 is given, so a row written by an older version cannot trigger that
|
||||||
|
either.
|
||||||
|
|
||||||
#### Entrypoint
|
#### Entrypoint
|
||||||
|
|
||||||
@@ -1917,7 +1967,7 @@ and application state:
|
|||||||
- **APIKeys** — programmatic access credentials
|
- **APIKeys** — programmatic access credentials
|
||||||
|
|
||||||
On first startup the main database is created from its SQL files (see
|
On first startup the main database is created from its SQL files (see
|
||||||
[Schema](#schema)), a session encryption key is generated and stored, and an
|
[Upgrades](#upgrades)), a session encryption key is generated and stored, and an
|
||||||
`admin` user is created.
|
`admin` user is created.
|
||||||
|
|
||||||
**Per-Webhook Event Databases** (`{DATA_DIR}/events-{webhook_uuid}.db`) — each
|
**Per-Webhook Event Databases** (`{DATA_DIR}/events-{webhook_uuid}.db`) — each
|
||||||
@@ -2114,21 +2164,6 @@ The database uses the
|
|||||||
though CGO is required at build time due to the transitive `mattn/go-sqlite3`
|
though CGO is required at build time due to the transitive `mattn/go-sqlite3`
|
||||||
dependency from `gorm.io/driver/sqlite`.
|
dependency from `gorm.io/driver/sqlite`.
|
||||||
|
|
||||||
#### Schema
|
|
||||||
|
|
||||||
Each kind of database file has its schema in SQL files built into the binary,
|
|
||||||
one directory per kind under `internal/db/migrations/`. In each,
|
|
||||||
`000_migration.sql` creates the `schema_migrations` table, which records the
|
|
||||||
number of every file the database has had, and `001_schema.sql` holds the whole
|
|
||||||
schema. Opening a database runs, in one transaction, every file in its directory
|
|
||||||
that `schema_migrations` does not list:
|
|
||||||
|
|
||||||
- `main/` for the main database, on connect — settings, users, API keys,
|
|
||||||
webhooks, entrypoints and targets
|
|
||||||
- `events/` for each event database, when it is lazily opened — events,
|
|
||||||
deliveries, delivery results and their running totals
|
|
||||||
- `archive/` for each archive database, on every open and reopen
|
|
||||||
|
|
||||||
### Request Flow
|
### Request Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -3419,14 +3454,12 @@ fixed independently of the compiler's:
|
|||||||
phases and the `css-check` and `markdown-check` stages passing (it copies a
|
phases and the `css-check` and `markdown-check` stages passing (it copies a
|
||||||
file from each), runs `make build` (which extracts Alpine.js from `3p/`
|
file from each), runs `make build` (which extracts Alpine.js from `3p/`
|
||||||
first), and then rebuilds the binary with `CGO_ENABLED=1` and static linking
|
first), and then rebuilds the binary with `CGO_ENABLED=1` and static linking
|
||||||
so it runs on musl, with the build tags `netgo`, `osusergo` and
|
so it runs on musl. Both builds go through `make build`, the relink adding
|
||||||
`sqlite_omit_load_extension` so it needs none of glibc's shared libraries at
|
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that stamps
|
||||||
run time. Both builds go through `make build`, the relink adding its
|
the version. The version is the `VERSION` build arg if one is given,
|
||||||
`-extldflags` via `GO_LDFLAGS` and its tags via `GO_TAGS`, so neither can
|
otherwise derived from the `.git` in the context, and the stage fails if a
|
||||||
drop the `-X` that stamps the version. The version is the `VERSION` build arg
|
context with `.git` would stamp an empty version, `dev` or `unknown` (see
|
||||||
if one is given, otherwise derived from the `.git` in the context, and the
|
[Version stamping](#version-stamping)).
|
||||||
stage fails if a context with `.git` would stamp an empty version, `dev` or
|
|
||||||
`unknown` (see [Version stamping](#version-stamping)).
|
|
||||||
7. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
7. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||||
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for
|
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for
|
||||||
all SQLite databases, exposes port 8080, and includes a health check against
|
all SQLite databases, exposes port 8080, and includes a health check against
|
||||||
|
|||||||
@@ -12,14 +12,24 @@ One issue per unit of work, one branch and one PR per issue:
|
|||||||
`main` without notice. One `next` -> `main` PR accumulates the milestone;
|
`main` without notice. One `next` -> `main` PR accumulates the milestone;
|
||||||
releases are cut from `main` separately.
|
releases are cut from `main` separately.
|
||||||
|
|
||||||
The tracker (https://git.eeqj.de/sneak/webhooker/issues) is the record of what
|
Issue branches do NOT touch this file — the manager maintains it on `next`.
|
||||||
is done and what is under way. This file keeps no list of either.
|
Every branch editing `TODO.md` conflicts with every other (#112).
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
What is still open for 1.0.0 is in its milestone
|
The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the
|
||||||
(https://git.eeqj.de/sneak/webhooker/milestone/9). `v1.0.0` is not tagged, and
|
authoritative list, and the only place to read a count or a state of play from.
|
||||||
only @sneak tags it.
|
This file records where the project is, not what is in flight: a sentence whose
|
||||||
|
truth depends on a branch being unmerged is wrong the moment it merges, and this
|
||||||
|
file has been wrong that way before.
|
||||||
|
|
||||||
|
The durability defect that held the tag has landed
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`). Every SQLite
|
||||||
|
handle opens with WAL journaling and a busy timeout, a bookkeeping write that
|
||||||
|
fails leaves its delivery in a recoverable state rather than a lying one, and
|
||||||
|
recovery skips a delivery that already has a successful result row. Final
|
||||||
|
pre-tag verification exercised it and confirmed it holds. Whatever the milestone
|
||||||
|
still shows open is what remains before `v1.0.0`.
|
||||||
|
|
||||||
Delivery is at-least-once by design, not by accident: a send whose result row
|
Delivery is at-least-once by design, not by accident: a send whose result row
|
||||||
does not land is attempted again, so a receiver can see a duplicate. That is
|
does not land is attempted again, so a receiver can see a duplicate. That is
|
||||||
@@ -28,31 +38,338 @@ under Rationale. It is not a defect to re-file.
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
@sneak runs webhooker in limited production, then tags `v1.0.0`. Merging `next`
|
Clear the rest of the open 1.0.0 milestone
|
||||||
into `main` is a separate act from tagging and waits on neither of those: `next`
|
(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`. Merging
|
||||||
is kept mergeable at all times, which is the point of the branch.
|
`next` into `main` is a separate act from tagging and waits on neither of those:
|
||||||
|
`next` is kept mergeable at all times, which is the point of the branch.
|
||||||
|
|
||||||
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-08-24 Bind the plaintext HTTP listener deliberately, via `BIND_ADDRESS`
|
||||||
|
defaulting to `127.0.0.1`, and document the reverse-proxy deployment. A
|
||||||
|
hostname, an empty value or a value carrying a port is a startup error, and
|
||||||
|
the `Dockerfile` sets `0.0.0.0` because a loopback bind inside a container is
|
||||||
|
unreachable (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit
|
||||||
|
removed the shutdown race: `httpServer` is built in the constructor rather
|
||||||
|
than assigned from the serving goroutine, which orders the write before every
|
||||||
|
fx hook and rules out the nil dereference a SIGTERM arriving first would have
|
||||||
|
caused, and `sentryEnabled` is an `atomic.Bool`
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/226)
|
||||||
|
- 2026-08-24 Remove inbound request signature verification. The entrypoint UUID
|
||||||
|
is the authentication secret, so the per-entrypoint shared secret, the
|
||||||
|
`internal/signature` package, the receiver check, the model fields and the
|
||||||
|
forms are all gone. This reverses the feature that landed earlier in the same
|
||||||
|
milestone (https://git.eeqj.de/sneak/webhooker/issues/67,
|
||||||
|
https://git.eeqj.de/sneak/webhooker/issues/279)
|
||||||
|
- 2026-08-24 Stamp the build version into the binary and render it in the UI
|
||||||
|
footer. `script/version` is the single source — `$VERSION`, else
|
||||||
|
`git describe --tags --always --dirty`, else `unknown` — so a `make build`
|
||||||
|
binary and a `make docker` image from one checkout report the same thing, and
|
||||||
|
nothing in it varies between two builds of the same commit, which the release
|
||||||
|
gate's byte-identical assertion would catch
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/253)
|
||||||
|
- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the request
|
||||||
|
transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a real TLS proxy
|
||||||
|
with the environment left at its `dev` default, the session cookie silently
|
||||||
|
lost `Secure` while the CSRF cookie on the same response kept it.
|
||||||
|
`X-Forwarded-Proto` is now matched case-insensitively on its first
|
||||||
|
comma-separated element, so `HTTPS` and `https, http` no longer fall to the
|
||||||
|
relaxed CSRF path (https://git.eeqj.de/sneak/webhooker/issues/269)
|
||||||
|
- 2026-08-24 Roll back a failed webhook deletion instead of committing it. A
|
||||||
|
failing delete committed whatever had already succeeded, hard-deleted the
|
||||||
|
per-webhook event database anyway, and redirected as though it had worked —
|
||||||
|
orphaned config plus permanently destroyed history, reported as success. All
|
||||||
|
three delete positions now roll back with the event database intact
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/262)
|
||||||
|
- 2026-08-24 Name a deleted target on its historical deliveries, marked
|
||||||
|
`(deleted)`, rather than leaving the event log unable to say where a delivery
|
||||||
|
went. A deleted target's credentials stay masked exactly as a live one's, and
|
||||||
|
it cannot become deliverable again through the receiver, resubmit, replay, the
|
||||||
|
edit form or the toggle (https://git.eeqj.de/sneak/webhooker/issues/211)
|
||||||
|
- 2026-08-24 Bound both request-controlled `/metrics` label dimensions, so the
|
||||||
|
unauthenticated receiver is no longer a memory-exhaustion vector: `handler`
|
||||||
|
carries the chi route pattern, and `method` folds anything chi cannot route
|
||||||
|
onto a single `(unmatched)` sentinel. Both were reproduced before the fix —
|
||||||
|
300 random method tokens took the series count from 106 to 7,631, and path
|
||||||
|
flooding reached 62,532 — and a label audit across a live scrape found no
|
||||||
|
third unbounded dimension (https://git.eeqj.de/sneak/webhooker/issues/254,
|
||||||
|
https://git.eeqj.de/sneak/webhooker/issues/261)
|
||||||
|
- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7` and `-5`
|
||||||
|
silently became 0 — fire-and-forget — including on the edit path, where it
|
||||||
|
destroyed a working value, and `999999999` stored verbatim. The ceiling of 20
|
||||||
|
is the `max` both templates already declared
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/221)
|
||||||
|
- 2026-08-24 Resubmit a stored event as a new undelivered event, so a backend
|
||||||
|
under development can be tested against real captured traffic. Per-delivery
|
||||||
|
replay cannot serve that: it re-sends one finished delivery to its own
|
||||||
|
original target, and a target created for a dev backend has no prior delivery
|
||||||
|
to replay. Resubmit re-injects the stored event at the top of the receiver
|
||||||
|
path and fans it out to whatever targets are active now
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/250)
|
||||||
|
- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two instances
|
||||||
|
on one directory cannot both deliver
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/201)
|
||||||
|
- 2026-08-20 Shut down the app when the HTTP listener fails. The `OnStart` hook
|
||||||
|
returned as soon as the serving goroutine was spawned, so a failed listen left
|
||||||
|
fx reporting RUNNING and a live process with nothing bound — invisible to
|
||||||
|
systemd and Docker restart policies
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/200)
|
||||||
|
- 2026-08-20 Stop target credentials leaking into the per-webhook event
|
||||||
|
databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL with
|
||||||
|
placeholders rather than bound values
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on half-set
|
||||||
|
metrics auth credentials (https://git.eeqj.de/sneak/webhooker/issues/205)
|
||||||
|
- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps GORM's own
|
||||||
|
trace recorder in for the logging adapter, and that recorder does not
|
||||||
|
implement `gorm.ParamsFilter`, so those statements logged their bound values
|
||||||
|
interpolated and bypassed the suppression above. The two units gated green
|
||||||
|
against a `next` that lacked the other, and `next` went red when both landed
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/234)
|
||||||
|
- 2026-08-20 Render per-attempt delivery detail in the event log
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a
|
||||||
|
terminally failed delivery (https://git.eeqj.de/sneak/webhooker/issues/203)
|
||||||
|
- 2026-08-20 Expose delivery metrics on `/metrics`
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/209) and document the backup,
|
||||||
|
restore and upgrade procedures
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/210)
|
||||||
|
- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap banner. The
|
||||||
|
admin bootstrap password was printed once among roughly 45 fx lines, and under
|
||||||
|
`docker run -d` went to container logs subject to rotation; there was no reset
|
||||||
|
path at all, so recovery meant hand-deleting the users row, documented
|
||||||
|
nowhere. The password is read from stdin or generated, never from argv where
|
||||||
|
`/proc` would publish it (https://git.eeqj.de/sneak/webhooker/issues/208)
|
||||||
|
- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch for the
|
||||||
|
SSRF guard, so a self-hosted proxy can forward into the operator's own
|
||||||
|
network. The guard's always-blocked set cannot be reopened by configuration
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/204)
|
||||||
|
- 2026-08-20 Harden operator-set target headers, which were carried unsafely
|
||||||
|
across a redirect (https://git.eeqj.de/sneak/webhooker/issues/233)
|
||||||
|
- 2026-08-20 Add a target edit form with headers and timeout fields
|
||||||
|
(https://git.eeqj.de/sneak/webhooker/issues/127)
|
||||||
|
- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s, matching
|
||||||
|
the org-wide backstop. `go test` applies `-timeout` per package, and
|
||||||
|
`internal/handlers` had grown past the old budget: a cache-defeated build
|
||||||
|
failed outright at `GOMAXPROCS=4`, and every run under deliberate host load
|
||||||
|
breached 30s. The measurement table lives in the script (#194)
|
||||||
|
- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy was stale
|
||||||
|
and still mandated a 20s test target with a 30s timeout, which the org
|
||||||
|
replaced with a 60s cap and a 90s backstop. A synced copy is not a source;
|
||||||
|
reading it as one nearly produced a PR against `prompts` proposing a change
|
||||||
|
already merged there (#196)
|
||||||
|
- 2026-08-18 Report handler panics through the logger and answer 500. chi
|
||||||
|
v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no longer emits,
|
||||||
|
then indexes `pkg[-1:]`, so it panicked inside its own stack printer before
|
||||||
|
writing a byte: the recovery never ran, the client got a dropped connection
|
||||||
|
instead of a 500, and the original panic was lost. A local middleware replaces
|
||||||
|
it, bounded by `MaxPanicLogLineBytes` (#187)
|
||||||
|
- 2026-08-18 Route GORM's logger through `slog` and bound it. Every `gorm.Open`
|
||||||
|
left `logger.Default` in place at `Warn` with `IgnoreRecordNotFoundError`
|
||||||
|
false, so **every record-not-found printed the fully interpolated SQL to
|
||||||
|
stdout** — including the client-chosen path on `/webhook/{uuid}` and the
|
||||||
|
submitted username on the login form, at no level the operator set and outside
|
||||||
|
`internal/logger` entirely. Three call sites, not the two the issue named
|
||||||
|
(#178)
|
||||||
|
- 2026-08-18 Bound every `slog` line against client-chosen text. Eight sites
|
||||||
|
reachable unauthenticated, found by reading every `slog` call in the tree
|
||||||
|
rather than only the one reported; the budget moved to a shared
|
||||||
|
`internal/logfield` so no second truncation exists. `DEBUG` being off by
|
||||||
|
default is not a bound and is not treated as one (#176)
|
||||||
|
- 2026-08-18 Stop a slow host turning a login-guard test into a segfault. A
|
||||||
|
non-fatal `assert` on an acquire result was dereferenced on the next line, so
|
||||||
|
one timing miss killed the whole `internal/middleware` binary and reddened CI
|
||||||
|
for unrelated PRs. The fix also removed a real production race — `acquire`
|
||||||
|
could shed a request with a slot standing free, because Go picks uniformly
|
||||||
|
among ready `select` cases (#186)
|
||||||
|
- 2026-08-18 Send the chi route pattern to Sentry rather than the concrete path.
|
||||||
|
The receiver's path carries the entrypoint capability token, so every Sentry
|
||||||
|
event from `/webhook/{uuid}` shipped a live credential to a third party.
|
||||||
|
Request `Data`, `QueryString`, `Cookies` and `Env` are dropped and headers
|
||||||
|
reduced to an allowlist (#179)
|
||||||
|
- 2026-08-18 Read form fields from the POST body only. `r.FormValue` merges the
|
||||||
|
query string, so a login could be driven by URL parameters — putting the
|
||||||
|
password somewhere that lands in access logs, proxy logs and browser history
|
||||||
|
(#160)
|
||||||
|
- 2026-08-18 Verify login credentials before spending rate-limit budget, so a
|
||||||
|
flood of wrong passwords cannot lock out the account it is guessing at. The
|
||||||
|
manager took this decision rather than stall the queue; it is flagged on the
|
||||||
|
issue for reversal (#150)
|
||||||
|
- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint was
|
||||||
|
wrong in both directions from version skew and shared caches. `script/lint`
|
||||||
|
asserts the summary line, because `--no-cache-filter` silently ignores a stage
|
||||||
|
name it does not match — the flag that makes the gate meaningful fails open
|
||||||
|
(#109)
|
||||||
|
- 2026-08-18 Serve an event's full stored body over HTTP. The list query
|
||||||
|
truncates for rendering, and that truncated value was the only way to read a
|
||||||
|
body, so the full payload was unreachable (#157)
|
||||||
|
- 2026-08-18 Bound the access log line against client-chosen text.
|
||||||
|
`internal/logfield` budgets by _encoded_ bytes, not runes, so a handler's JSON
|
||||||
|
escaping cannot multiply a field past its allowance (#146)
|
||||||
|
- 2026-08-18 Mark superseded CI commits `failure` rather than `skipped`. A
|
||||||
|
skipped run rolls up green, so a commit that was never tested reported success
|
||||||
|
(#152)
|
||||||
|
- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so shutdown
|
||||||
|
hooks are bounded by a deadline the orchestrator will actually honour rather
|
||||||
|
than being killed mid-flush (#134)
|
||||||
|
- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation hands out
|
||||||
|
2^64 addresses, so per-address keying let one client mint unlimited buckets.
|
||||||
|
Manager decision, recorded on the issue (#125)
|
||||||
|
- 2026-08-17 Correct release-blocking README and startup-warning inaccuracies,
|
||||||
|
including claims about behaviour the code does not have (#151)
|
||||||
|
- 2026-08-17 Fetch and verify Alpine.js at build time against
|
||||||
|
`static/vendor.sha256` instead of committing the minified blob, so the
|
||||||
|
dependency is pinned by hash rather than by trust (#145)
|
||||||
|
- 2026-08-17 Bound the event log's rendered bodies in the query itself, so a
|
||||||
|
large stored payload cannot be read into memory just to be truncated for
|
||||||
|
display (#135)
|
||||||
|
- 2026-08-17 Mask the `http` target's destination URL in the UI: it can carry a
|
||||||
|
bearer credential in its path or query, and was rendered verbatim. Manager
|
||||||
|
decision to mask unconditionally (#115)
|
||||||
|
- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that hangs
|
||||||
|
cannot hold the process past its grace period (#102)
|
||||||
|
- 2026-08-14 Render templates via a buffer rather than the `ResponseWriter`, so
|
||||||
|
a template error part-way through cannot commit a 200 and then fail — the
|
||||||
|
response is written only once it is whole (#123)
|
||||||
|
- 2026-08-14 Align the session codec's max-age with the 7-day absolute cap. The
|
||||||
|
codec accepted cookies the session layer considered expired, so the cap was
|
||||||
|
enforced in one place and not the other (#108)
|
||||||
|
- 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where the safe
|
||||||
|
default silently discards forwarded headers and every client rate-limits as
|
||||||
|
the proxy's address (#149)
|
||||||
|
- 2026-08-12 Bound the receiver rate limit per client IP across the whole
|
||||||
|
`/webhook/*` route. The existing limiter keyed on the request path and
|
||||||
|
`/webhook/{uuid}` matches any single segment, so a client that invented a
|
||||||
|
fresh path per request minted a fresh bucket per request: the limit on the
|
||||||
|
only unauthenticated endpoint bounded nothing in aggregate, and every request
|
||||||
|
still cost an entrypoint lookup before it 404ed. An outer limiter keyed on the
|
||||||
|
client address alone now bounds that, chained in front of the unchanged
|
||||||
|
per-entrypoint limiter (#139)
|
||||||
|
- 2026-08-12 Correct release-blocking documentation inaccuracies: the README
|
||||||
|
promised manual redelivery in the present tense in three places when nothing
|
||||||
|
implements it (the same false claim also sat in the doc comment that was its
|
||||||
|
source text), the env table omitted `RETENTION_SWEEP_INTERVAL`, and `TODO.md`
|
||||||
|
itself omitted five landed units (#141)
|
||||||
|
- 2026-08-12 Make the CI gate execute the checks it reports on. The workflow now
|
||||||
|
writes a build-context fingerprint before calling `script/cibuild`, so a code
|
||||||
|
commit invalidates the `COPY` layer of the lint and builder stages while a
|
||||||
|
docs-only commit still replays from cache; a superseding run also rewrites the
|
||||||
|
`failure` status Gitea leaves on commits it cancelled and never tested.
|
||||||
|
Verified by pushing a deliberately broken test and watching CI go red (#119)
|
||||||
|
- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a non-positive value
|
||||||
|
reached `time.NewTicker` in both the retention reaper and the archive sweeper,
|
||||||
|
panicking two goroutines with no recover after startup had already reported
|
||||||
|
success (#140)
|
||||||
|
- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop cap: the
|
||||||
|
reverse walk cuts entries with `strings.LastIndexByte` instead of joining and
|
||||||
|
splitting, so a 1 MB header allocates 16 bytes rather than 1.6 MB per request
|
||||||
|
on the unauthenticated receiver. Semantics proven unchanged by differential
|
||||||
|
testing against the previous implementation (#133)
|
||||||
|
- 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an
|
||||||
|
attacker-supplied chain cannot burn unbounded CPU in the rate-limit key
|
||||||
|
function; running off the end falls back to the peer address (#124)
|
||||||
|
- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR list:
|
||||||
|
all three rate limiters key on the connection's own address unless the direct
|
||||||
|
peer is a configured proxy, in which case `X-Forwarded-For` is walked right to
|
||||||
|
left for the first non-proxy hop. Default trusts nothing, and a
|
||||||
|
set-but-unparseable value aborts startup. Before this, any client could mint a
|
||||||
|
fresh bucket or drain another's by rotating a spoofed header (#88)
|
||||||
|
- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the Profile
|
||||||
|
settings placeholder removed, a progressive-enhancement copy button for the
|
||||||
|
entrypoint URL, and retention form copy that states the actual policy
|
||||||
|
(deletion by the reaper, 0 retains forever) (#57)
|
||||||
|
- 2026-08-11 Mask the webhook credential in delivery errors and logs: Go embeds
|
||||||
|
the request URL in `*url.Error`, so every transport failure persisted the full
|
||||||
|
Slack webhook URL into the per-webhook event database via
|
||||||
|
`DeliveryResult.Error`, a field a future REST API would have served.
|
||||||
|
`maskURLError` drops path, query and userinfo while preserving the wrapped
|
||||||
|
cause, so `errors.Is`/`As` and `Timeout()` still work and DNS, TLS and timeout
|
||||||
|
failures still read differently (#118)
|
||||||
|
- 2026-08-11 Rate-limit the public webhook receiver endpoint
|
||||||
|
(`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus entrypoint
|
||||||
|
path so one entrypoint cannot exhaust another's budget; over-limit requests
|
||||||
|
get 429 with `Retry-After`. It was the one unauthenticated, internet-facing
|
||||||
|
endpoint with no limit at all (#64)
|
||||||
|
- 2026-08-11 Enforce the body size limit before CSRF parses the form:
|
||||||
|
`MaxBodySize` is now first in all four form-parsing route groups, so an
|
||||||
|
oversized request is rejected with 413 instead of being read in full by the
|
||||||
|
CSRF middleware before any cap applied (#90)
|
||||||
|
- 2026-08-11 Mask target config on the source detail page, which rendered the
|
||||||
|
stored blob verbatim and so exposed the Slack incoming-webhook URL — a bearer
|
||||||
|
credential that cannot be revoked per-holder. Config reaches the template only
|
||||||
|
as a `TargetView` of labelled fields, and header values are rendered as a
|
||||||
|
count (#113)
|
||||||
|
- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a sentinel
|
||||||
|
written in `BeforeSave` so the GORM column default cannot win the race. Also
|
||||||
|
bounds the reaper's cutoff arithmetic: day counts above 106751 overflowed
|
||||||
|
`time.Duration` and wrapped the cutoff into the future, where every row
|
||||||
|
matched and the sweep deleted everything (#79)
|
||||||
|
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry
|
||||||
|
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated requests,
|
||||||
|
with the 7-day absolute cap kept as an independent backstop that activity
|
||||||
|
never extends (#66)
|
||||||
|
- 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an
|
||||||
|
orphaned `retrying` delivery whose target type no longer supports retries,
|
||||||
|
recording a `DeliveryResult` with the reason instead of leaving the delivery
|
||||||
|
stuck forever (#82)
|
||||||
|
- 2026-08-09 Root the delivery engine's worker pool and the retention reaper's
|
||||||
|
sweep loop at `context.Background()` rather than the fx `OnStart` hook context
|
||||||
|
(#97), which carries fx's 15s start timeout and killed both roughly fifteen
|
||||||
|
seconds after boot: the proxy silently stopped delivering webhooks entirely,
|
||||||
|
and the reaper never ran a single sweep under its default one-hour interval
|
||||||
|
- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its last
|
||||||
|
`database` target) evicts the cached archive writer and closes its handle
|
||||||
|
while deliberately leaving `archive-{webhookID}.db` on disk, and a new
|
||||||
|
`ArchiveSweeper` prunes idle archives on the existing
|
||||||
|
`RETENTION_SWEEP_INTERVAL` without ever creating an archive file
|
||||||
|
- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable
|
||||||
|
environment values: `envInt` removed in favour of `envPositiveInt` plus a
|
||||||
|
`PORT` range check, `envBool` now parses with `strconv.ParseBool`, and
|
||||||
|
defaults apply only to unset variables (#80)
|
||||||
|
- 2026-08-07 Automatic event retention cleanup based on `retention_days`,
|
||||||
|
deleting expired events, deliveries, and delivery results from each
|
||||||
|
per-webhook event database (#63)
|
||||||
|
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
|
||||||
|
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`), adopt the
|
||||||
|
canonical `.golangci.yml` (v2 `linters.settings` layout so
|
||||||
|
`lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix all newly
|
||||||
|
surfaced lint findings
|
||||||
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
||||||
|
shims, README Entrypoints section
|
||||||
|
- 2026-03-25 pin golangci-lint Docker image for linting (#55)
|
||||||
|
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over plain
|
||||||
|
HTTP and behind reverse proxies (#54)
|
||||||
|
- 2026-03-17 root path redirects based on auth state (#52)
|
||||||
|
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets with DNS
|
||||||
|
rebinding defense, and per-IP login rate limiting (#42)
|
||||||
|
- 2026-03-17 Slack target type for incoming webhook notifications (#47)
|
||||||
|
- 2026-03-17 Dockerfile absolute paths and static linking (#49); absolute dev
|
||||||
|
DATA_DIR default and clarified env docs (#46)
|
||||||
|
- 2026-03-05 security headers middleware, session regeneration on login, request
|
||||||
|
body size limits (#41)
|
||||||
|
- 2026-03-04 tests for delivery, middleware, and session packages (#32); removed
|
||||||
|
the build-architecture global (#31)
|
||||||
|
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core delivery
|
||||||
|
engine with bounded worker pool and circuit breaker, parallel fan-out,
|
||||||
|
per-webhook event databases, management UI (#16)
|
||||||
|
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded into README
|
||||||
|
(#6)
|
||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- Per-webhook rate limits, set in the web UI and enforced in the receiver
|
- Delivery status and retry management UI. Replay of a terminally failed
|
||||||
handler, on top of the receiver's existing limits, which `RECEIVER_RATE_LIMIT`
|
delivery and per-attempt detail already landed
|
||||||
sets for the whole server (see Rate Limiting in the README)
|
(https://git.eeqj.de/sneak/webhooker/issues/203,
|
||||||
- API key authentication for programmatic access (the `APIKey` model exists, but
|
https://git.eeqj.de/sneak/webhooker/issues/202)
|
||||||
nothing creates a key and no middleware checks one)
|
- Per-webhook rate limiting in the receiver handler (per-webhook config plus
|
||||||
|
handler enforcement; global limits must not apply to receiver endpoints)
|
||||||
|
- API key authentication for programmatic access (APIKey model exists; Bearer
|
||||||
|
token middleware does not)
|
||||||
- REST API v1
|
- REST API v1
|
||||||
- CRUD for webhooks, entrypoints, targets
|
- CRUD for webhooks, entrypoints, targets
|
||||||
- event viewing and filtering endpoints
|
- event viewing and filtering endpoints
|
||||||
- event redelivery endpoint
|
- event redelivery endpoint
|
||||||
- OpenAPI specification
|
- OpenAPI specification
|
||||||
- Analytics in the web UI: response times summarised per webhook or per target,
|
- Analytics dashboard: success rates, response times, volume
|
||||||
and figures for all webhooks together. The webhook page already shows event,
|
- A remember-me option at login
|
||||||
delivery and failure counts and failure percentages, the target list counts
|
|
||||||
each target's deliveries, the event log and each event's page show every
|
|
||||||
attempt's duration, and `/metrics` carries server-wide counts and delivery
|
|
||||||
durations
|
|
||||||
- A remember-me option at login. Every session already survives a browser
|
|
||||||
restart: it lasts up to 7 days, and ends sooner after `SESSION_IDLE_TIMEOUT`
|
|
||||||
(default 24 hours) without activity
|
|
||||||
- Password reset flow for a forgotten password over the web. The authenticated
|
- Password reset flow for a forgotten password over the web. The authenticated
|
||||||
password _change_ flow already landed, and a lost password is recoverable from
|
password _change_ flow already landed, and a lost password is recoverable from
|
||||||
the console with `webhooker resetpw`
|
the console with `webhooker resetpw`
|
||||||
@@ -60,13 +377,7 @@ is kept mergeable at all times, which is the point of the branch.
|
|||||||
- Later, nice to have
|
- Later, nice to have
|
||||||
- email delivery target type
|
- email delivery target type
|
||||||
- SNS and S3 delivery targets
|
- SNS and S3 delivery targets
|
||||||
- data transformations (e.g. a configurable Slack message format; the Slack
|
- data transformations (e.g. webhook to Slack message formatting)
|
||||||
target sends one fixed format today)
|
- JSONL file delivery with periodic S3 upload
|
||||||
- JSONL file delivery with periodic S3 upload (the `database` target already
|
- webhook event search and filtering
|
||||||
archives events to SQLite files, rotated hourly, daily or monthly if set,
|
- multi-user with role-based access control
|
||||||
and the webhook page offers them as one gzipped JSON download)
|
|
||||||
- webhook event search, and filtering beyond the event log's failed and
|
|
||||||
pending views
|
|
||||||
- multi-user with role-based access control (each webhook already belongs to
|
|
||||||
one account, but there is only the account made at first boot and no way
|
|
||||||
to add another)
|
|
||||||
@@ -45,7 +45,8 @@ type Event struct {
|
|||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied from by
|
// ResubmittedFromID names the event this one was copied from by
|
||||||
// an operator resubmit. It is nil for an event that arrived on
|
// an operator resubmit. It is nil for an event that arrived on
|
||||||
// the receiver. It is not a foreign key: the source event can be
|
// the receiver, which is every event created before the column
|
||||||
|
// existed. It is not a foreign key: the source event can be
|
||||||
// reaped by retention while its copies remain, and the id is
|
// reaped by retention while its copies remain, and the id is
|
||||||
// kept as the record of where the copy came from either way.
|
// kept as the record of where the copy came from either way.
|
||||||
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
|
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ type Webhook struct {
|
|||||||
// value while building the insert statement, which runs after
|
// value while building the insert statement, which runs after
|
||||||
// BeforeSave; rewriting any later than this loses that race and the
|
// BeforeSave; rewriting any later than this loses that race and the
|
||||||
// row lands at 30 days. Living on the model also means a future call
|
// row lands at 30 days. Living on the model also means a future call
|
||||||
// site, such as a REST API or a fixture, cannot bypass it.
|
// site — a REST API, a fixture, a migration — cannot bypass it.
|
||||||
func (w *Webhook) BeforeSave(_ *gorm.DB) error {
|
func (w *Webhook) BeforeSave(_ *gorm.DB) error {
|
||||||
if w.RetentionDays <= 0 {
|
if w.RetentionDays <= 0 {
|
||||||
w.RetentionDays = RetentionForeverDays
|
w.RetentionDays = RetentionForeverDays
|
||||||
@@ -98,8 +98,8 @@ func (w *Webhook) BeforeSave(_ *gorm.DB) error {
|
|||||||
// are exempt from reaping.
|
// are exempt from reaping.
|
||||||
//
|
//
|
||||||
// It accepts the RetentionForeverDays sentinel written by BeforeSave
|
// It accepts the RetentionForeverDays sentinel written by BeforeSave
|
||||||
// and, defensively, any non-positive value, which BeforeSave treats
|
// and, defensively, the non-positive values that rows written before
|
||||||
// as a request to retain forever.
|
// the sentinel existed may still carry.
|
||||||
func retainsForever(retentionDays int) bool {
|
func retainsForever(retentionDays int) bool {
|
||||||
return retentionDays <= 0 ||
|
return retentionDays <= 0 ||
|
||||||
retentionDays >= RetentionForeverDays
|
retentionDays >= RetentionForeverDays
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ func (r *RetentionReaper) reapWebhook(
|
|||||||
// row in the database: the sweep then deletes every event, delivery,
|
// row in the database: the sweep then deletes every event, delivery,
|
||||||
// and delivery result, including ones created seconds ago. Rejecting
|
// and delivery result, including ones created seconds ago. Rejecting
|
||||||
// out-of-range input at the form is the primary guard; saturating here
|
// out-of-range input at the form is the primary guard; saturating here
|
||||||
// means a value that reaches the row by any other path cannot turn a
|
// means an old row, a migration, or a future call site cannot turn a
|
||||||
// too-large retention into total data loss.
|
// too-large retention into total data loss.
|
||||||
func retentionCutoff(
|
func retentionCutoff(
|
||||||
now time.Time,
|
now time.Time,
|
||||||
|
|||||||
@@ -118,8 +118,9 @@ const SQLiteFilePerm fs.FileMode = 0o600
|
|||||||
// SQLite still reports the absence rather than this function
|
// SQLite still reports the absence rather than this function
|
||||||
// materializing an empty database the caller asked not to create.
|
// materializing an empty database the caller asked not to create.
|
||||||
//
|
//
|
||||||
// The mode is set on every open, whoever created the file, so a
|
// Chmod of a file that already exists is what tightens a data
|
||||||
// database or sidecar found at a wider mode is tightened too.
|
// directory an earlier build left at 0644 — including a developer's
|
||||||
|
// own scratch directory — without any migration machinery.
|
||||||
func reserveSQLiteFile(path string, create bool) error {
|
func reserveSQLiteFile(path string, create bool) error {
|
||||||
if create {
|
if create {
|
||||||
// gosec G304: the path is the database file the caller asked
|
// gosec G304: the path is the database file the caller asked
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package database_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -121,6 +120,7 @@ func TestMainDBAppliesPragmasOnALiveHandle(t *testing.T) {
|
|||||||
func TestOpenSQLiteOpensThePathAskedFor(t *testing.T) {
|
func TestOpenSQLiteOpensThePathAskedFor(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
dir := filepath.Join(t.TempDir(), "hooks#1?x%41")
|
dir := filepath.Join(t.TempDir(), "hooks#1?x%41")
|
||||||
require.NoError(t, os.Mkdir(dir, 0o700))
|
require.NoError(t, os.Mkdir(dir, 0o700))
|
||||||
|
|
||||||
@@ -137,42 +137,15 @@ func TestOpenSQLiteOpensThePathAskedFor(t *testing.T) {
|
|||||||
|
|
||||||
t.Cleanup(func() { assert.NoError(t, sqlDB.Close()) })
|
t.Cleanup(func() { assert.NoError(t, sqlDB.Close()) })
|
||||||
|
|
||||||
assertOpenedFile(t, sqlDB, path)
|
// database_list names the file SQLite actually opened, with
|
||||||
}
|
// symlinks resolved, so the files are compared, not the names.
|
||||||
}
|
|
||||||
|
|
||||||
// TestOpenSQLiteOpensARelativePath opens a database at a path relative
|
|
||||||
// to the working directory, which is what a relative DATA_DIR such as
|
|
||||||
// the README's DATA_DIR=../webhooker-data gives OpenSQLite.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // t.Chdir moves the whole process.
|
|
||||||
func TestOpenSQLiteOpensARelativePath(t *testing.T) {
|
|
||||||
t.Chdir(t.TempDir())
|
|
||||||
require.NoError(t, os.Mkdir("data", 0o700))
|
|
||||||
|
|
||||||
path := filepath.Join("data", database.MainDBFileName)
|
|
||||||
|
|
||||||
sqlDB, err := database.OpenSQLite(path, database.SQLiteModeCreate)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, sqlDB.Close()) }()
|
|
||||||
|
|
||||||
assertOpenedFile(t, sqlDB, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertOpenedFile checks that sqlDB is the SQLite file at path.
|
|
||||||
// database_list names the file SQLite actually opened, as an absolute
|
|
||||||
// path with symlinks resolved, so the files are compared, not the names.
|
|
||||||
func assertOpenedFile(t *testing.T, sqlDB *sql.DB, path string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
seq int
|
seq int
|
||||||
schema, file string
|
schema, file string
|
||||||
)
|
)
|
||||||
|
|
||||||
require.NoError(t, sqlDB.
|
require.NoError(t, sqlDB.
|
||||||
QueryRowContext(t.Context(), "pragma database_list").
|
QueryRowContext(ctx, "pragma database_list").
|
||||||
Scan(&seq, &schema, &file))
|
Scan(&seq, &schema, &file))
|
||||||
|
|
||||||
asked, err := os.Stat(path)
|
asked, err := os.Stat(path)
|
||||||
@@ -186,6 +159,7 @@ func assertOpenedFile(t *testing.T, sqlDB *sql.DB, path string) {
|
|||||||
"asked for %s, SQLite opened %s", path, file,
|
"asked for %s, SQLite opened %s", path, file,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestConcurrentReaderDoesNotBlockWrites is the unit-scale form of the
|
// TestConcurrentReaderDoesNotBlockWrites is the unit-scale form of the
|
||||||
// reproduction in
|
// reproduction in
|
||||||
|
|||||||
@@ -201,7 +201,8 @@ func (s *ArchiveSweeper) sweep(ctx context.Context) {
|
|||||||
|
|
||||||
// sweepTarget prunes the archive of a single database target.
|
// sweepTarget prunes the archive of a single database target.
|
||||||
// A missing, empty, or "never" expiry parses as a zero duration
|
// A missing, empty, or "never" expiry parses as a zero duration
|
||||||
// and is skipped entirely, so those archives are never pruned.
|
// and is skipped entirely, so those archives keep exactly the
|
||||||
|
// behaviour they had before the sweep existed.
|
||||||
func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
|
func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
|
||||||
expiry, err := parseArchiveExpiry(target.Config)
|
expiry, err := parseArchiveExpiry(target.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ func isReservedTargetHeader(name string) bool {
|
|||||||
// one pair silently overwriting the other.
|
// one pair silently overwriting the other.
|
||||||
//
|
//
|
||||||
// An input with no pairs yields an empty map, which omitempty drops
|
// An input with no pairs yields an empty map, which omitempty drops
|
||||||
// from the stored config, so a target configured with no headers
|
// from the stored config: a target configured with no headers keeps
|
||||||
// stores no headers key at all.
|
// the same config JSON it had before this field existed.
|
||||||
func ParseTargetHeaders(raw string) (map[string]string, error) {
|
func ParseTargetHeaders(raw string) (map[string]string, error) {
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import (
|
|||||||
const RedactionMarker = "(redacted)"
|
const RedactionMarker = "(redacted)"
|
||||||
|
|
||||||
// Redactor removes one target's own credential material from
|
// Redactor removes one target's own credential material from
|
||||||
// a delivery response body, whose text the target's remote
|
// text that target's remote peer chose: a delivery response
|
||||||
// peer chose, and from a stored delivery error as a second
|
// body, or a delivery error stored before the delivery path
|
||||||
// line behind the delivery path's own URL masking.
|
// learned to mask the URLs it embeds.
|
||||||
//
|
//
|
||||||
// It removes byte-identical echoes of strings taken from the
|
// It removes byte-identical echoes of strings taken from the
|
||||||
// target's stored configuration, and nothing else. Anything
|
// target's stored configuration, and nothing else. Anything
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ func TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms(t *testing.T) {
|
|||||||
{database.TargetTypeLog, true, 0, "", "written to the log", ""},
|
{database.TargetTypeLog, true, 0, "", "written to the log", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Named for type and outcome, so that no two cases share a name:
|
||||||
|
// Go adds "#01" to a repeated one, and a "#" in the temporary
|
||||||
|
// directory's path cuts the path SQLiteDSN gives SQLite short.
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(string(tc.targetType)+" "+tc.outcome, func(t *testing.T) {
|
t.Run(string(tc.targetType)+" "+tc.outcome, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ import (
|
|||||||
//
|
//
|
||||||
// The bound is enforced here and in SQL, because this page's
|
// The bound is enforced here and in SQL, because this page's
|
||||||
// memory profile must not depend on a constant in another
|
// memory profile must not depend on a constant in another
|
||||||
// package staying where it is, or on every stored row having
|
// package staying where it is, and because rows predating the
|
||||||
// been written by the delivery engine under its own cap.
|
// delivery engine's own cap or restored from an archive are
|
||||||
|
// not covered by it at all.
|
||||||
//
|
//
|
||||||
// It happens to equal that engine cap, so a row written by the
|
// It happens to equal that engine cap, so a row written by the
|
||||||
// engine reaches this one exactly and is never cut twice.
|
// current engine reaches this one exactly and is never cut
|
||||||
// Nothing here may assume the two differ: see view.
|
// twice. Nothing here may assume the two differ: see view.
|
||||||
const maxRenderedResponseBytes = 4096
|
const maxRenderedResponseBytes = 4096
|
||||||
|
|
||||||
// deliveryResultColumns is the delivery attempt projection.
|
// deliveryResultColumns is the delivery attempt projection.
|
||||||
@@ -39,8 +40,9 @@ const deliveryResultColumns = "delivery_id, attempt_num, created_at, " +
|
|||||||
//
|
//
|
||||||
// Both Error and ResponseBody have been through the target's
|
// Both Error and ResponseBody have been through the target's
|
||||||
// Redactor. The engine already masks the URL out of the
|
// Redactor. The engine already masks the URL out of the
|
||||||
// errors it stores, so for errors this is a second line; for
|
// errors it stores, so for errors this is a second line
|
||||||
// response bodies it is the only line, and its reach is what
|
// covering rows written before it did; for response bodies it
|
||||||
|
// is the only line, and its reach is what
|
||||||
// delivery.Redactor documents.
|
// delivery.Redactor documents.
|
||||||
type DeliveryResultView struct {
|
type DeliveryResultView struct {
|
||||||
AttemptNum int
|
AttemptNum int
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ var (
|
|||||||
// working retry configuration over a typo. A default answers a
|
// working retry configuration over a typo. A default answers a
|
||||||
// question that was not asked; it never answers one that was asked
|
// question that was not asked; it never answers one that was asked
|
||||||
// badly.
|
// badly.
|
||||||
|
//
|
||||||
|
// A target stored with a count above the ceiling before this
|
||||||
|
// validation existed keeps rendering and keeps delivering — nothing
|
||||||
|
// clamps the row. Re-saving it from the edit form does have to bring
|
||||||
|
// it into range, because the form submits the pre-filled value back
|
||||||
|
// and accepting it would be the ceiling not applying to the edit
|
||||||
|
// path. The 400 names the ceiling, so the fix is one field.
|
||||||
func parseMaxRetries(raw string, fallback int) (int, error) {
|
func parseMaxRetries(raw string, fallback int) (int, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -441,9 +441,10 @@ func (s *Session) Regenerate(
|
|||||||
//
|
//
|
||||||
// Whichever comes first ends the session.
|
// Whichever comes first ends the session.
|
||||||
//
|
//
|
||||||
// A session that claims to be authenticated but lacks the timestamp
|
// A session that claims to be authenticated but carries no
|
||||||
// a deadline is measured from is treated as expired, so the user
|
// timestamps predates this check; it is treated as expired so the
|
||||||
// re-authenticates rather than being granted an unbounded session.
|
// user re-authenticates rather than being granted an unbounded
|
||||||
|
// session.
|
||||||
func (s *Session) expired(sess *sessions.Session) bool {
|
func (s *Session) expired(sess *sessions.Session) bool {
|
||||||
now := s.now()
|
now := s.now()
|
||||||
|
|
||||||
|
|||||||
@@ -156,8 +156,7 @@ func TestVersion_IsStableAcrossInvocations(t *testing.T) {
|
|||||||
// The build target is the only place composing linker flags. If a
|
// The build target is the only place composing linker flags. If a
|
||||||
// future edit drops either half, the binary silently reports "dev"
|
// future edit drops either half, the binary silently reports "dev"
|
||||||
// again (the defect this package exists for) or fails to link on
|
// again (the defect this package exists for) or fails to link on
|
||||||
// Alpine. If it stops passing GO_TAGS, the image's static binary links
|
// Alpine.
|
||||||
// glibc's getaddrinfo and dlopen again.
|
|
||||||
func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
|
func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -165,7 +164,6 @@ func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
|
|||||||
|
|
||||||
require.Contains(t, makefile, "-X main.version=$(VERSION)")
|
require.Contains(t, makefile, "-X main.version=$(VERSION)")
|
||||||
require.Contains(t, makefile, "$(GO_LDFLAGS)")
|
require.Contains(t, makefile, "$(GO_LDFLAGS)")
|
||||||
require.Contains(t, makefile, "-tags '$(GO_TAGS)'")
|
|
||||||
require.Contains(t, makefile, "VERSION ?= $(shell script/version)")
|
require.Contains(t, makefile, "VERSION ?= $(shell script/version)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,9 +233,7 @@ func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) {
|
|||||||
"a raw go build bypasses the Makefile's -X flag")
|
"a raw go build bypasses the Makefile's -X flag")
|
||||||
require.Contains(t, dockerfile, "ARG VERSION")
|
require.Contains(t, dockerfile, "ARG VERSION")
|
||||||
require.Contains(t, dockerfile,
|
require.Contains(t, dockerfile,
|
||||||
`make build VERSION="$VERSION"`+
|
`make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`)
|
||||||
` GO_TAGS=netgo,osusergo,sqlite_omit_load_extension`+
|
|
||||||
` GO_LDFLAGS='-extldflags "-static"'`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func read(t *testing.T, path string) string {
|
func read(t *testing.T, path string) string {
|
||||||
|
|||||||
Reference in new issue
Block a user