Upphovsman SHA1 Meddelande Datum
clawbot 3eb77b96a2 Add a read-only Settings page for the loaded configuration (closes #402)
check / check (push) Waiting to run
The page at /settings, behind the login and linked from the
navigation bar, lists every field of the configuration the server
started with: each by its environment variable name, with the
README's description and the value in effect. METRICS_PASSWORD and
SENTRY_DSN show only as set or not set; their values never reach the
template. The handlers now take the loaded Config from the dependency
graph.

Model: opus-5-5
2026-10-02 09:32:16 +00:00
clawbot c513816a55 Refuse [::], 0.0.0.0, IPv6 multicast and documentation space (closes #341)
check / check (push) Waiting to run
On the build host a connection to [::] reaches a listener on ::1, and one to 0.0.0.0 reaches 127.0.0.1, so a delivery target at either reached this host's loopback past the guard. 0.0.0.0/32 and ::/128 are now in alwaysBlockedNetworks, which no allowlist opens; an allowlist reaches loopback only through an entry covering a loopback address. IPv6 multicast (ff00::/8) and documentation space (2001:db8::/32) are refused by default and reopen when listed.

Every default blocklist entry has a one-line comment, each list is pinned on its own, and tests refuse each address at target creation and at delivery. The README and the rules above each list match.

Model: opus-5-5
2026-10-02 11:22:30 +02:00
clawbot 2bb4683512 Discard fx's own log in tests that build an fx app (closes #230)
check / check (push) Waiting to run
Every test that builds an fx app with fxtest.New (handlers, server, resetpw, gormlog, config) now passes fx.NopLogger, so fx's own log no longer goes to t.Logf. A hook still running after a start or stop timeout can then no longer write to a test that has already returned, which the race detector reported as a data race. What the tests assert is unchanged, and nothing about the race detector is suppressed.

Model: opus-5-5
2026-10-02 11:08:38 +02:00
33 ändrade filer med 1272 tillägg och 1724 borttagningar
+107 -117
Visa fil
@@ -145,6 +145,11 @@ TTY detection, and security headers are always applied.
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
The Settings page of the web UI (`/settings`, behind the login) lists
every one of these with the value the running server loaded. It is
read-only, and it shows `METRICS_PASSWORD` and `SENTRY_DSN` only as
set or not set, never their values.
#### Allowing egress to your own network
By default every delivery target must resolve to a public address. The
@@ -158,19 +163,20 @@ WireServer, which serves an Azure VM its credentials. Because it is a
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it.
That is all the default blocklist covers: the IPv4 private and reserved
ranges; of IPv6, only loopback (`::1`), unique local addresses
(`fc00::/7`) and link-local addresses (`fe80::/10`); and certain public
addresses. A public address belongs on the default blocklist only if it
hands credentials, user data or bootstrap material to whatever can reach
it, without the caller presenting anything. A provider's other public
addresses are not refused. IBM Cloud, for example, serves its package
mirrors, time servers and object storage on `161.26.0.0/16`, and the
private endpoints of its own cloud services on `166.8.0.0/14`. Neither
range hands out credentials that way: the token service among those
endpoints issues a token only in exchange for something the caller
presents, such as an API key. Reaching these services can be a
legitimate delivery, and every cloud has some, so a partial list would
promise coverage it does not give.
ranges; of IPv6, only loopback (`::1`), the unspecified address (`::`),
unique local addresses (`fc00::/7`), link-local addresses (`fe80::/10`),
multicast (`ff00::/8`) and documentation space (`2001:db8::/32`); and
certain public addresses. A public address belongs on the default
blocklist only if it hands credentials, user data or bootstrap material
to whatever can reach it, without the caller presenting anything. A
provider's other public addresses are not refused. IBM Cloud, for
example, serves its package mirrors, time servers and object storage on
`161.26.0.0/16`, and the private endpoints of its own cloud services on
`166.8.0.0/14`. Neither range hands out credentials that way: the token
service among those endpoints issues a token only in exchange for
something the caller presents, such as an API key. Reaching these
services can be a legitimate delivery, and every cloud has some, so a
partial list would promise coverage it does not give.
That default is also inconvenient for the thing webhooker is mostly
for: taking a public webhook and forwarding it to something on your own
@@ -210,16 +216,16 @@ Two things this setting cannot do:
the list is always an allowlist; an empty list (the default) means
every private and reserved range stays refused. Note that
`0.0.0.0/0` gets you most of the way there anyway, per above.
- **It cannot open link-local, or a cloud metadata endpoint at a
non-public address that discloses credentials or user data.** An
address is on the list below when it is not a public address and both
of these hold: the provider fixes it, so it cannot collide with
anything you run; and reaching it hands out credentials, user data or
bootstrap material. Those stay blocked no matter what you list,
including when you list them outright or list a supernet such as
`0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`. Treat this as best
effort rather than a guarantee — it is a hand-maintained list and the
caveat below the table applies:
- **It cannot open link-local, the unspecified addresses, or a cloud
metadata endpoint at a non-public address that discloses credentials
or user data.** A metadata address is on the list below when it is not
a public address and both of these hold: the provider fixes it, so it
cannot collide with anything you run; and reaching it hands out
credentials, user data or bootstrap material. Those stay blocked no
matter what you list, including when you list them outright or list a
supernet such as `0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`.
Treat this as best effort rather than a guarantee — it is a
hand-maintained list and the caveat below the table applies:
| Blocked unconditionally | What it is |
| ----------------------- | ---------- |
@@ -233,14 +239,25 @@ Two things this setting cannot do:
| `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 |
| `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT |
| `192.0.0.192/32` | Oracle Cloud Classic metadata |
| `0.0.0.0/32` | IPv4 unspecified address, which reaches this host's loopback on Linux |
| `::/128` | IPv6 unspecified address, which reaches this host's loopback on Linux |
| `::a9fe:a9fe/128` | `169.254.169.254` as an IPv4-compatible IPv6 address |
| `64:ff9b::a9fe:a9fe/128` | `169.254.169.254` behind the NAT64 well-known prefix |
The IPv4-mapped form `::ffff:169.254.169.254` is covered by the
`169.254.0.0/16` entry. Reaching any of these is credential or
user-data theft rather than delivery to an internal service. Every
entry outside the two link-local blocks is a single address, so
blocking it costs you nothing else on the network around it.
`169.254.0.0/16` entry. Reaching any of these but the two unspecified
addresses is credential or user-data theft rather than delivery to an
internal service. Every entry outside the two link-local blocks is a
single address, so blocking it costs you nothing else on the network
around it.
The unspecified addresses `0.0.0.0` and `::` hand out nothing
themselves, but no host can have either, and on Linux a connection to
one reaches this host's own loopback. They are listed so that an
allowlist reaches loopback only through an entry that covers a loopback
address, such as `127.0.0.0/8`, `::1` or `0.0.0.0/0`, never through one
that covers only `0.0.0.0` or `::`; `0.0.0.0/8`, for example, does not
open loopback.
The six ULA entries, all inside `fd00::/8`, are why this matters in
practice: `fd00::/8` is an ordinary block to allowlist for your own
@@ -698,8 +715,7 @@ The app runs as a non-root user (`webhooker`, UID 1000), exposes port
The `/var/lib/webhooker` volume holds all SQLite databases: the main
application database (`webhooker.db`), the per-webhook event databases
(`events-{uuid}.db`), and any archive databases written by `database`
targets (`archive-{webhook_name}-{target_name}-{target_uuid}.db`). Mount
this as a persistent volume to
targets (`archive-{uuid}.db`). Mount this as a persistent volume to
preserve data across container restarts.
**The container sets its data directory's owner and mode itself
@@ -938,13 +954,13 @@ is both the simplest and the only complete rule:
encryption key), users, API keys, webhooks, entrypoints, targets.
- `events-{webhook_uuid}.db` — **one per webhook**. Events, deliveries,
delivery results.
- `archive-{webhook_name}-{target_name}-{target_uuid}.db` — **one per
`database` target**. Archived events. The two names are made safe for
a file name, and the file is renamed when the webhook or the target is
(see [Database Architecture](#database-architecture)).
- `archive-{webhook_uuid}.db` — **one per webhook that has a `database`
target**. Archived events. Keyed on the webhook UUID, not the target
UUID: a webhook with several `database` targets still has exactly one
archive file.
`{webhook_uuid}` and `{target_uuid}` are UUID primary keys in their
canonical 36-character hyphenated form, so a real filename looks like
`{webhook_uuid}` is the webhook's UUID primary key in its canonical
36-character hyphenated form, so a real filename looks like
`events-3f2a1c9e-....db`. The only other file is `webhooker.lock`, the
always-empty [single-instance lock](#single-instance-lock); it holds no
state and is not part of the backup set — a copied one is stale and
@@ -1018,8 +1034,8 @@ stopped copy.
Archive databases are the one exception the service is built for: the
archive writer closes and reopens its handle around writes (debounced
to at most one reopen per second), so an operator can move an
`archive-….db` away for offline retention while the service runs,
to at most one reopen per second), so an operator can move
`archive-{uuid}.db` away for offline retention while the service runs,
and it is recreated on the next write. See
[Database Architecture](#database-architecture). That is a
move-the-file-away workflow, not a substitute for the backup procedures
@@ -1037,7 +1053,7 @@ happens on the next write past the debounce window, when the connection
pool retires the idle connection (about a minute after the last write),
or at the idle archive sweep — measured, the same file was a complete
20 KB `.db` with no sidecars about a minute after its last write. A
clean stop closes it too. So either move the `archive-….db` together
clean stop closes it too. So either move `archive-{uuid}.db` together
with any `-wal`/`-shm` beside it, or wait until there are none.
### Restore
@@ -1172,7 +1188,7 @@ commit still produce a byte-identical binary.
Treat a backup with the same care as the credentials inside it. Encrypt
backups at rest and restrict who can read them.
- `events-{uuid}.db` and `archive-….db` hold the **full payload
- `events-{uuid}.db` and `archive-{uuid}.db` hold the **full payload
body and headers** of every event as received, including whatever the
sending service put in them — tokens, signatures, personal data.
- Event databases written before
@@ -1590,9 +1606,8 @@ events should be forwarded.
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.
- **`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
- **`database`** — Archive the full event as a row into a separate
per-webhook archive database (`archive-{webhookID}.db`) for long-term
retention, with an optional creation-validated expiry (default: keep
forever). No external delivery and no retries; an archive write
failure fails the delivery. See the database target section under
@@ -1906,39 +1921,9 @@ The **database target type** builds on this architecture to provide
long-term archiving, separate from the per-webhook event database (which
may prune events under its own retention). Delivering to a database
target writes the full event — body, headers, method, content type, and
webhook/entrypoint/event identifiers — as a row into the target's own
archive database, `archive-{webhook_name}-{target_name}-{target_uuid}.db`,
stored under the data directory beside the event database. Each
`database` target has its own archive file, even when one webhook has
several.
Both names are made safe for a file name the same way: lowercased, ASCII
letters and digits kept, every other run of characters turned into a
single `-`, no `-` at either end, cut to 40 characters, and `unnamed`
when nothing is left. The 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`.
Renaming the webhook or the target renames the file, under the same
lock the archive writes and the archive sweeper take, so the name on
disk matches the UI. A rename never replaces a file: if one already has
the new name, the edit is refused with an error naming that file, and
the stored name stays. If the archive is not there (the operator moved
it away), the rename is not an error, and the next write creates the
file under the new name.
The file is moved just before the new name is saved. If the process
stops between the two, the archive is left under the new name while the
UI still shows the old one, and the next delivery starts a second
archive under the name shown. To bring them back together, stop the
service before moving anything, and move each archive as its `.db`
together with any `-wal` and `-shm` beside it, since the `-wal` can hold
rows that are not yet in the `.db`. If no file has the name shown, move
the archive under the new name back to it. If a second archive already
has the name shown, move the archive under the new name out of the data
directory instead and keep it as you would any archive moved away. Then
start the service again.
After each write the archive handle is closed
webhook/entrypoint/event identifiers — as a row into a dedicated archive
database, `archive-{webhookID}.db`, stored under the data directory
beside the event database. After each write the archive handle is closed
and reopened, debounced to at most once per second, so an operator can
move the archive file away for offline archiving without stopping the
service; a moved or removed archive file is recreated automatically on
@@ -1949,33 +1934,35 @@ 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 that gap: on the same interval as the event retention
reaper (`RETENTION_SWEEP_INTERVAL`) it prunes every archive whose
database target declares a positive expiry, whether or not the target
is still receiving 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.
Because reopens only happen on writes, an archive belonging to a webhook
that has stopped receiving events would never be pruned. A background
**archive sweeper** closes that gap: on the same interval as the event
retention reaper (`RETENTION_SWEEP_INTERVAL`) it prunes every archive
whose database target declares a positive expiry, whether or not the
webhook is still receiving traffic. The sweep never creates an archive —
a webhook whose archive file does not yet exist is skipped, not
initialised — it takes the same per-webhook 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.
Because each `database` target has its own archive file, a target's
`expiry` governs only its own archive. Two `database` targets on one
webhook with different expiries keep two archives, each pruned on its
own schedule.
Note that a webhook has one archive file but may carry more than one
`database` target, each with its own `expiry`. The shortest expiry
configured on any of them therefore governs the whole archive, and the
sweep applies it whether or not the webhook is still receiving events.
Configure a single `database` target per webhook unless you intend that.
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 archive: the delivery engine's cached
archive writer is dropped and its file handle closed, so nothing lingers
after the webhook is gone. The archive **file itself is 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 `archive-{webhookID}.db` is the operator's call.
Deleting a webhook's last `database` target releases the writer the same
way, and for the same reason leaves the file alone.
The **Slack target type** sends webhook events as formatted messages to
any Slack-compatible incoming webhook URL (works with Slack, Mattermost,
@@ -2810,6 +2797,7 @@ returns to the page that was asked for.
| ------ | ------------------------ | ----------- |
| `GET` | `/user/{username}` | User profile page |
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
| `GET` | `/settings` | Read-only list of the configuration the server is running with; `METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set |
| `GET` | `/hooks` | List user's webhooks |
| `GET` | `/hooks/new` | Create webhook form |
| `POST` | `/hooks/new` | Create webhook submission |
@@ -2923,6 +2911,7 @@ webhooker/
│ │ ├── healthcheck.go # Health check handler
│ │ ├── index.go # Index page handler
│ │ ├── profile.go # User profile handler
│ │ ├── settings.go # Read-only Settings page handler
│ │ ├── source_management.go # Webhook CRUD handlers
│ │ └── webhook.go # Webhook receiver handler
│ ├── healthcheck/
@@ -2987,8 +2976,8 @@ Components are wired via Uber fx in this order:
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 renaming a webhook or target reaches its archive files
16. `delivery.Engine` → `delivery.WebhookEvictor` — interface bridge so
deleting a webhook releases its archive writer
17. `server.New` — HTTP server and router
The server starts via `fx.Invoke(func(*server.Server, *delivery.Engine,
@@ -3032,14 +3021,14 @@ local record instead of nothing. What that placement gives up is
recovery of a panic in the six entries above it, none of which does
more than set a header or start a timer.
Each admin page route group (`/pages`, `/user/*`, `/hooks`,
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is
set, its own **Sentry** error reporting. That Recoverer answers a panic
with the `500` error page in the normal layout; the global one keeps
the plain-text `500` for every other route.
Each admin page route group (`/pages`, `/user/*`, `/settings`, `/hooks`,
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is set, its
own **Sentry** error reporting. That Recoverer answers a panic with the `500`
error page in the normal layout; the global one keeps the plain-text `500` for
every other route.
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`,
`/hook/*`) apply a **MaxBodySize** middleware that limits
Additionally, form endpoints (`/pages`, `/user/*`, `/settings`,
`/hooks`, `/hook/*`) apply a **MaxBodySize** middleware that limits
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
CSRF middleware in every one of those route groups, because
gorilla/csrf parses the form; if the cap were installed after it, form
@@ -3058,7 +3047,7 @@ declared length. A chunked request, or
one that lies about its length, is hard-capped by
`http.MaxBytesReader` and fails downstream at form-parse time.
Those same four route groups then apply **CSRF** and **NoCache**
Those same five route groups then apply **CSRF** and **NoCache**
(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except
`/pages` applies **RequireAuth**. The rate limiters are per-route
rather than global: **PasswordChangeRateLimit** on
@@ -3104,12 +3093,12 @@ check, see [The login endpoint](#the-login-endpoint).
by middleware that runs before CSRF parses the form
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
on all state-changing forms (cookie-based double-submit tokens with
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`, and
`/user` routes. Excluded from `/h` (inbound webhook POSTs) and
`/api` (stateless API). The middleware detects TLS per-request through
`internal/reqtls.IsTLS` — the same predicate the session cookie uses —
to set appropriate cookie security flags and Origin/Referer validation
mode
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`,
`/settings`, and `/user` routes. Excluded from `/h` (inbound webhook
POSTs) and `/api` (stateless API). The middleware detects TLS
per-request through `internal/reqtls.IsTLS` — the same predicate the
session cookie uses — to set appropriate cookie security flags and
Origin/Referer validation mode
- **The entrypoint URL is the receiver's only credential.** Nothing
about an inbound request is verified; possession of the UUID
authorises submission, and no shared secret or signature check will
@@ -3123,7 +3112,8 @@ check, see [The login endpoint](#the-login-endpoint).
route through a single decision function, so they cannot disagree
about a destination. An operator can permit specific blocks with
[`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the
guard cannot be switched off, and link-local plus a
guard cannot be switched off, and link-local, the unspecified
addresses `0.0.0.0` and `::`, and a
[pinned set](#allowing-egress-to-your-own-network) of known cloud
metadata endpoints — several of which are ULAs outside link-local —
stay blocked whatever is listed, though listing `0.0.0.0/0` or
+5 -4
Visa fil
@@ -192,10 +192,11 @@ func newApp() *fx.App {
// Wire *delivery.Engine as delivery.Notifier so the
// webhook handler can notify the engine of new deliveries.
func(e *delivery.Engine) delivery.Notifier { return e },
// Wire *delivery.Engine as delivery.Archives so deleting
// or renaming a webhook or target reaches its archive
// files.
func(e *delivery.Engine) delivery.Archives { return e },
// Wire *delivery.Engine as delivery.WebhookEvictor so
// deleting a webhook releases its archive writer.
func(e *delivery.Engine) delivery.WebhookEvictor {
return e
},
server.New,
),
fx.Invoke(
+7 -6
Visa fil
@@ -196,12 +196,13 @@ type Config struct {
// otherwise refuse. The guard itself is always on: there is no
// setting that disables SSRF protection, and delivery's
// alwaysBlockedNetworks stays blocked no matter what is listed
// here. That set is link-local plus the cloud metadata
// endpoints outside it that disclose credentials or user data
// at a provider-fixed, non-public address; it is not
// exhaustive of every cloud's metadata address. See
// alwaysBlockedNetworks for the authoritative list and the
// criterion it is built from.
// here. That set is link-local, the unspecified addresses
// 0.0.0.0 and ::, and the cloud metadata endpoints outside
// link-local that disclose credentials or user data at a
// provider-fixed, non-public address; it is not exhaustive of
// every cloud's metadata address. See
// alwaysBlockedNetworks for the authoritative list and why
// each entry is on it.
AllowedEgressCIDRs []netip.Prefix
params *ConfigParams
+12
Visa fil
@@ -124,6 +124,11 @@ func testEnvironmentConfigSuccess(
app := fxtest.New(
t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned. The same holds for every fxtest.New
// below.
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -272,6 +277,7 @@ func testRetentionSweepIntervalSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -364,6 +370,7 @@ func testSessionIdleTimeoutSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -404,6 +411,7 @@ func TestDefaultDataDir(t *testing.T) {
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -534,6 +542,7 @@ func testReceiverRateLimitSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -650,6 +659,7 @@ func testTrustedProxiesSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -763,6 +773,7 @@ func testAllowedEgressCIDRsSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -1006,6 +1017,7 @@ func assertMetricsAuthAccepted(t *testing.T, expectAuth bool) {
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(globals.New, logger.New, config.New),
fx.Populate(&cfg),
)
+12 -14
Visa fil
@@ -8,7 +8,6 @@ import (
"time"
"go.uber.org/fx"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/lifecycle"
@@ -26,14 +25,14 @@ type ArchiveSweeperParams struct {
Logger *logger.Logger
}
// ArchiveSweeper periodically prunes expired rows from the
// archive databases of database targets that carry a positive
// expiry.
// ArchiveSweeper periodically prunes expired rows from
// per-webhook archive databases whose database target carries a
// positive expiry.
//
// Without it, pruning happens only when an archive is
// (re)opened, and archives are only ever reopened by writes: an
// archive whose target has stopped receiving events would keep
// its expired rows forever. The sweep closes
// archive belonging to a webhook that has stopped receiving
// events would keep its expired rows forever. The sweep closes
// that gap without changing anything for archives whose expiry
// is unset or "never".
//
@@ -156,7 +155,7 @@ func (s *ArchiveSweeper) run(ctx context.Context) {
// soft-deleted along with it, so GORM's default scope already
// excludes them.
//
// A failure for one target is logged and the sweep continues,
// A failure for one webhook is logged and the sweep continues,
// matching how the write path already treats a prune error as
// non-fatal.
func (s *ArchiveSweeper) sweep(ctx context.Context) {
@@ -211,20 +210,19 @@ func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
return
}
err = s.eng.dbTarget.sweepArchive(target.ID, expiry)
err = s.eng.dbTarget.sweepWebhook(target.WebhookID, expiry)
if err == nil {
return
}
// A writer evicted, or a target row gone, underneath the sweep
// means the operator deleted the target or its webhook while
// the sweep was walking the target list. That is an ordinary
// A writer evicted underneath the sweep means the operator
// deleted the webhook (or its last database target) while the
// sweep was walking the target list. That is an ordinary
// interleaving, not a failure, so it must not produce an
// error line.
if errors.Is(err, errArchiveWriterEvicted) ||
errors.Is(err, gorm.ErrRecordNotFound) {
if errors.Is(err, errArchiveWriterEvicted) {
s.log.Debug(
"archive sweep: target deleted mid-sweep",
"archive sweep: writer evicted mid-sweep",
"webhook_id", target.WebhookID,
"target_id", target.ID,
)
+133 -143
Visa fil
@@ -34,23 +34,18 @@ const (
sweepConcurrentWrites = 20
)
// archiveTestWebhookName is the name of every webhook
// seedDatabaseTarget creates. It is not safe in a file name as it
// stands, so every archive test goes through archiveNamePart.
const archiveTestWebhookName = "Sweep Test!"
// archiveEnv bundles the pieces an archive test drives: a main
// configuration database holding webhooks and targets, a delivery
// engine owning the archive writer registry, the archive sweeper,
// and the data directory the archive files live in.
type archiveEnv struct {
// sweeperEnv bundles the pieces an archive sweep test drives:
// a main configuration database holding webhooks and targets, a
// delivery engine owning the archive writer registry, and the
// data directory the archive files live in.
type sweeperEnv struct {
sweeper *delivery.ArchiveSweeper
eng *delivery.Engine
mainDB *database.Database
dataDir string
}
func setupArchiveTest(t *testing.T) *archiveEnv {
func setupSweeperTest(t *testing.T) *sweeperEnv {
t.Helper()
dataDir := t.TempDir()
@@ -83,7 +78,7 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
1,
)
return &archiveEnv{
return &sweeperEnv{
sweeper: delivery.NewTestArchiveSweeper(
mainDB, eng, log,
),
@@ -93,27 +88,25 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
}
}
// archivePath returns where the engine keeps a database target's
// archive file, for the names seedDatabaseTarget gave it.
func (env *archiveEnv) archivePath(tgt *database.Target) string {
// archivePath returns where the engine keeps a webhook's
// archive file.
func (env *sweeperEnv) archivePath(webhookID string) string {
return filepath.Join(
env.dataDir,
delivery.ArchiveFileName(
archiveTestWebhookName, tgt.Name, tgt.ID,
),
env.dataDir, fmt.Sprintf("archive-%s.db", webhookID),
)
}
// seedDatabaseTarget creates a webhook with one database target
// carrying the given target config JSON, and returns the target.
func (env *archiveEnv) seedDatabaseTarget(
// carrying the given target config JSON, and returns the
// webhook id.
func (env *sweeperEnv) seedDatabaseTarget(
t *testing.T, configJSON string,
) *database.Target {
) string {
t.Helper()
wh := &database.Webhook{
UserID: uuid.New().String(),
Name: archiveTestWebhookName,
Name: "sweep-test",
}
require.NoError(
t,
@@ -122,19 +115,9 @@ func (env *archiveEnv) seedDatabaseTarget(
Create(wh).Error,
)
return env.addDatabaseTarget(t, wh.ID, configJSON)
}
// addDatabaseTarget creates one more database target on an
// existing webhook and returns it.
func (env *archiveEnv) addDatabaseTarget(
t *testing.T, webhookID, configJSON string,
) *database.Target {
t.Helper()
tgt := &database.Target{
WebhookID: webhookID,
Name: "Archive",
WebhookID: wh.ID,
Name: "archive",
Type: database.TargetTypeDatabase,
Active: true,
Config: configJSON,
@@ -146,19 +129,19 @@ func (env *archiveEnv) addDatabaseTarget(
Create(tgt).Error,
)
return tgt
return wh.ID
}
// seedArchiveRows creates the archive file for a target and
// seedArchiveRows creates the archive file for a webhook and
// inserts one row per supplied archived-at timestamp, returning
// the archive path. The handle is closed before returning, so
// the archive is idle exactly as it would be with no traffic.
func (env *archiveEnv) seedArchiveRows(
t *testing.T, tgt *database.Target, archivedAt ...time.Time,
func (env *sweeperEnv) seedArchiveRows(
t *testing.T, webhookID string, archivedAt ...time.Time,
) string {
t.Helper()
path := env.archivePath(tgt)
path := env.archivePath(webhookID)
sqlDB, err := sql.Open(
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
@@ -177,7 +160,7 @@ func (env *archiveEnv) seedArchiveRows(
for i, at := range archivedAt {
row := delivery.ExportArchivedEvent{
EventID: fmt.Sprintf("ev-%d", i),
WebhookID: tgt.WebhookID,
WebhookID: webhookID,
Method: http.MethodPost,
Body: `{"seeded":true}`,
ArchivedAt: at,
@@ -260,13 +243,13 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
now := time.Now()
path := env.seedArchiveRows(
t, tgt,
t, webhookID,
now.Add(-48*time.Hour),
now.Add(-time.Minute),
)
@@ -304,60 +287,60 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
}
// TestArchiveSweep_DoesNotResurrectEvictedWriter covers the
// interleaving where a sweep tick has already listed a target
// when the target is deleted and its writer evicted. The sweep
// must not put a writer back into the registry: nothing would
// ever evict it again, which is precisely the leak this change
// exists to close.
// interleaving where a sweep tick has already listed a webhook's
// target when the webhook is deleted and its writer evicted. The
// sweep must not put a writer back into the registry: nothing
// would ever evict it again, which is precisely the leak this
// change exists to close.
func TestArchiveSweep_DoesNotResurrectEvictedWriter(
t *testing.T,
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour),
t, webhookID, time.Now().Add(-48*time.Hour),
)
// Prime the registry the way a delivery would, then evict as
// the deletion path does. The target row is deliberately left
// in place: this is the tick that listed the target before
// in place: this is the tick that listed the webhook before
// the deletion committed.
_, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
_, err := env.eng.ExportEnsureArchiveWriter(webhookID)
require.NoError(t, err)
env.eng.EvictTarget(tgt.ID)
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
env.eng.EvictWebhook(webhookID)
require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
env.sweeper.ExportSweep(context.Background())
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
"a sweep must never re-register a writer for a target "+
t, env.eng.ExportHasArchiveWriter(webhookID),
"a sweep must never re-register a writer for a webhook "+
"whose registry entry has already been released",
)
}
// TestArchiveSweep_LeavesNoRegistryEntry states the same
// invariant in its general form: sweeping an archive whose
// target has no cached writer must not leave one behind, so the
// webhook has no cached writer must not leave one behind, so the
// registry keeps holding only writers a delivery created and an
// eviction can reach.
func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows(
t, tgt,
t, webhookID,
time.Now().Add(-48*time.Hour),
time.Now().Add(-time.Minute),
)
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
env.sweeper.ExportSweep(context.Background())
@@ -366,7 +349,7 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
"the sweep must still prune an idle archive",
)
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, env.eng.ExportHasArchiveWriter(webhookID),
"the sweep must release the registry entry it created",
)
}
@@ -381,31 +364,34 @@ func TestArchiveSweep_KeepsWriterAdoptedByDelivery(
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour),
t, webhookID, time.Now().Add(-48*time.Hour),
)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
event.WebhookID = webhookID
d := seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"1h"}`,
)
env.sweeper.ExportSweep(context.Background())
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
env.eng.ExportDeliverDatabase(webhookDB, d)
assert.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, env.eng.ExportHasArchiveWriter(webhookID),
"a delivery's writer must stay registered",
)
env.sweeper.ExportSweep(context.Background())
assert.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, env.eng.ExportHasArchiveWriter(webhookID),
"a sweep must not drop a writer a delivery owns",
)
}
@@ -437,15 +423,15 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour),
t, webhookID, time.Now().Add(-48*time.Hour),
)
sweepWriter, created, err := env.eng.ExportSweepWriterFor(
tgt.ID,
webhookID,
)
require.NoError(t, err)
require.True(
@@ -456,34 +442,37 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
// The delivery lands mid-sweep and adopts the entry.
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
event.WebhookID = webhookID
d := seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"1h"}`,
)
env.eng.ExportDeliverDatabase(webhookDB, d)
adopted := env.eng.ExportArchiveWriterFor(tgt.ID)
adopted := env.eng.ExportArchiveWriterFor(webhookID)
require.NotNil(t, adopted)
require.True(
t, sweepWriter.Same(adopted),
"the delivery must have adopted the sweep's writer",
)
require.True(
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
t, env.eng.ExportArchiveHandleOpen(webhookID),
"the delivery leaves the archive handle open",
)
// The sweep finishes.
env.eng.ExportReleaseSweepWriter(tgt.ID, sweepWriter)
env.eng.ExportReleaseSweepWriter(webhookID, sweepWriter)
require.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, env.eng.ExportHasArchiveWriter(webhookID),
"a writer adopted by a delivery during a sweep must "+
"stay registered, or its open handle is unreachable",
)
env.eng.EvictTarget(tgt.ID)
env.eng.EvictWebhook(webhookID)
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, env.eng.ExportHasArchiveWriter(webhookID),
"the adopted writer must still be evictable",
)
assert.False(
@@ -492,34 +481,34 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
)
}
// TestArchiveSweep_ContinuesAfterPerTargetFailure proves a
// failure for one target does not abort the sweep for the
// TestArchiveSweep_ContinuesAfterPerWebhookFailure proves a
// failure for one webhook does not abort the sweep for the
// others: an unparseable expiry and an unreadable archive both
// have to be logged and stepped over.
func TestArchiveSweep_ContinuesAfterPerTargetFailure(
func TestArchiveSweep_ContinuesAfterPerWebhookFailure(
t *testing.T,
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
// Seeded first so the sweep reaches them before the healthy
// target: targets come back in insertion order.
badConfig := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`)
// webhook: targets come back in insertion order.
badConfigID := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`)
env.seedArchiveRows(
t, badConfig, time.Now().Add(-48*time.Hour),
t, badConfigID, time.Now().Add(-48*time.Hour),
)
corrupt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
corruptID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
require.NoError(t, os.WriteFile(
env.archivePath(corrupt),
env.archivePath(corruptID),
[]byte("this is not a sqlite database"),
0o600,
))
healthy := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
healthyID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
healthyPath := env.seedArchiveRows(
t, healthy,
t, healthyID,
time.Now().Add(-48*time.Hour),
time.Now().Add(-time.Minute),
)
@@ -529,14 +518,14 @@ func TestArchiveSweep_ContinuesAfterPerTargetFailure(
assert.Equal(
t, []string{sweepRowNew},
archivedEventIDs(t, healthyPath),
"a failure for an earlier target must not stop the "+
"a failure for an earlier webhook must not stop the "+
"sweep from pruning the ones after it",
)
}
// TestArchiveSweep_OpenExistingDoesNotCreateFile pins the second
// of the two no-create guards. The first is the stat in
// sweepExpired; this one is the SQLite open mode, which is what
// sweepWebhook; this one is the SQLite open mode, which is what
// protects the window between that stat and the open. Flipping
// the sweep's mode to create-if-missing makes this fail.
func TestArchiveSweep_OpenExistingDoesNotCreateFile(
@@ -572,13 +561,13 @@ func TestArchiveSweep_OpenExistingDoesNotCreateFile(
func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
now := time.Now()
path := env.seedArchiveRows(
t, tgt,
t, webhookID,
now.Add(-48*time.Hour),
now.Add(-time.Minute),
)
@@ -611,11 +600,11 @@ func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour),
t, webhookID, time.Now().Add(-48*time.Hour),
)
w := delivery.NewExportArchiveWriter(
@@ -651,32 +640,35 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter(
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour),
t, webhookID, time.Now().Add(-48*time.Hour),
)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
event.WebhookID = webhookID
d := seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"1h"}`,
)
env.eng.ExportDeliverDatabase(webhookDB, d)
require.True(
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
t, env.eng.ExportArchiveHandleOpen(webhookID),
"the delivery must leave the archive handle open",
)
env.sweeper.ExportSweep(context.Background())
require.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, env.eng.ExportHasArchiveWriter(webhookID),
"the delivery's registry entry must survive the sweep",
)
assert.False(
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
t, env.eng.ExportArchiveHandleOpen(webhookID),
"the sweep must leave the archive closed",
)
}
@@ -692,11 +684,11 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
`{"expiry":""}`,
"",
} {
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, configJSON)
webhookID := env.seedDatabaseTarget(t, configJSON)
path := env.seedArchiveRows(
t, tgt,
t, webhookID,
time.Now().Add(-10000*time.Hour),
)
@@ -707,7 +699,7 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
"config %q must keep rows forever", configJSON,
)
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, env.eng.ExportHasArchiveWriter(webhookID),
"config %q must leave no registry entry behind",
configJSON,
)
@@ -730,10 +722,10 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"never"}`)
path := env.archivePath(tgt)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"never"}`)
path := env.archivePath(webhookID)
seedUnmigratedArchive(t, path)
require.False(t, archiveTableExists(t, path))
@@ -776,16 +768,16 @@ func archiveTableExists(t *testing.T, path string) bool {
}
// TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep
// never conjures an archive: a database target that has never
// received an event must still have no archive file (nor SQLite
// sidecar) after a sweep, and no registry entry either.
// never conjures an archive: a webhook with a database target
// that has never received an event must still have no archive
// file (nor SQLite sidecar) after a sweep.
func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.archivePath(tgt)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.archivePath(webhookID)
require.NoFileExists(t, path)
@@ -797,11 +789,6 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
"the sweep must not create an archive file",
)
}
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the sweep must leave no registry entry behind",
)
}
// TestArchiveSweep_DoesNotCreateAfterWriterExists covers the
@@ -813,11 +800,11 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
path, err := env.eng.ExportEnsureArchiveWriter(webhookID)
require.NoError(t, err)
require.NoFileExists(t, path)
@@ -832,17 +819,17 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour),
t, webhookID, time.Now().Add(-48*time.Hour),
)
require.NoError(
t,
env.mainDB.DB().
Where("webhook_id = ?", tgt.WebhookID).
Where("webhook_id = ?", webhookID).
Delete(&database.Target{}).Error,
)
@@ -855,14 +842,14 @@ func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
}
// TestArchiveSweep_ConcurrentWrites proves the sweep serialises
// against writes through the target's writer mutex. Run under
// -race, an unsynchronised sweep would be caught here.
// against writes through the per-webhook writer mutex. Run
// under -race, an unsynchronised sweep would be caught here.
func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookDB := testWebhookDB(t)
@@ -875,10 +862,13 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
for range sweepConcurrentWrites {
event := seedEvent(t, webhookDB, `{"n":1}`)
event.WebhookID = webhookID
deliveries = append(
deliveries,
seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"1h"}`,
),
)
}
@@ -904,7 +894,7 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
wg.Wait()
assert.FileExists(t, env.archivePath(tgt))
assert.FileExists(t, env.archivePath(webhookID))
}
// TestArchiveSweeper_StopsCleanly proves the background loop
@@ -912,11 +902,11 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
func TestArchiveSweeper_StopsCleanly(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour),
t, webhookID, time.Now().Add(-48*time.Hour),
)
env.sweeper.ExportSetInterval(time.Millisecond)
@@ -940,7 +930,7 @@ func TestArchiveSweeper_StopHookHonoursStopTimeout(
) {
t.Parallel()
env := setupArchiveTest(t)
env := setupSweeperTest(t)
lc := &recordingLifecycle{}
env.sweeper.ExportRegisterHooks(lc)
+20 -51
Visa fil
@@ -123,24 +123,21 @@ type Notifier interface {
Notify(tasks []Task)
}
// Archives is how the handlers keep the database targets' archive
// files in step with the configuration. Deleting a webhook or a
// target releases the cached archive writers, whose open file
// handles would otherwise outlive them; renaming one renames the
// archive files, which are named for the webhook and the target
// (see ArchiveFileName).
// WebhookEvictor releases the delivery engine's per-webhook
// state for a webhook that no longer needs it — currently the
// cached archive writer of the database target, whose open
// file handle would otherwise outlive the webhook.
//
// It is deliberately separate from Notifier: archiving lifecycle
// is not notification, and a small interface keeps the handlers
// package free of any dependency on the engine's internals while
// staying trivially fakeable in tests.
// It is deliberately separate from Notifier and deliberately
// one method wide: archiving lifecycle is not notification, and
// a single-method interface keeps the handlers package free of
// any dependency on the engine's internals while staying
// trivially fakeable in tests.
//
// Neither eviction deletes an archive file. Both are idempotent
// and are no-ops for a webhook or target with no engine state.
type Archives interface {
// EvictWebhook never deletes an archive file. It is idempotent
// and is a no-op for a webhook with no engine state.
type WebhookEvictor interface {
EvictWebhook(webhookID string)
EvictTarget(targetID string)
Rename(targetID, webhookName, targetName string) error
}
// EngineParams are the fx dependencies for the delivery
@@ -191,7 +188,7 @@ type Engine struct {
httpTarget *httpTarget
// dbTarget is retained so the engine can reach the archive
// writer registry for eviction, renames and the idle sweep.
// writer registry for webhook eviction and the idle sweep.
dbTarget *databaseTarget
// inflight is the set of deliveries this engine currently owns.
@@ -260,44 +257,17 @@ func (e *Engine) Notify(tasks []Task) {
}
}
// EvictWebhook implements Archives. The cached archive writer of
// every database target of the webhook is dropped from the
// registry and its file handle closed. The archive files
// themselves are left on disk — they are long-term storage the
// operator owns.
// EvictWebhook implements WebhookEvictor. It releases the
// engine's per-webhook archiving state: the database target's
// cached archive writer is dropped from the registry and its
// file handle closed. The archive file itself is left on disk
// — it is long-term storage the operator owns.
func (e *Engine) EvictWebhook(webhookID string) {
if e.dbTarget == nil {
return
}
e.dbTarget.evictWebhook(webhookID)
}
// EvictTarget implements Archives. It is EvictWebhook for a single
// database target, and leaves the archive file on disk the same
// way.
func (e *Engine) EvictTarget(targetID string) {
if e.dbTarget == nil {
return
}
e.dbTarget.evict(targetID)
}
// Rename implements Archives. It renames a database target's
// archive file to ArchiveFileName(webhookName, targetName,
// targetID), under the lock the target's archive writes and the
// idle sweep take. It never replaces a file: if one already has the
// new name, the error is ErrArchiveNameTaken. The caller renames
// before it saves the new name: see databaseTarget.rename.
func (e *Engine) Rename(
targetID, webhookName, targetName string,
) error {
if e.dbTarget == nil {
return nil
}
return e.dbTarget.rename(targetID, webhookName, targetName)
e.dbTarget.evict(webhookID)
}
// ScheduleRetry schedules a task to be re-enqueued onto the
@@ -411,8 +381,7 @@ func (e *Engine) start() {
// Once the pool has drained it closes the archive writers, so a
// clean stop leaves no archive -wal behind. Nothing else holds a
// writer for long by then: the archive sweeper stops before the
// engine, and deleting or renaming a webhook or target only closes
// or moves one. If the pool did
// engine, and deleting a webhook only closes one. If the pool did
// not drain in time, the writers are left open, as a kill would
// leave them. Closing them would wait for any write in progress,
// and a worker still running would then open new writers that
+10 -23
Visa fil
@@ -2,6 +2,7 @@ package delivery_test
import (
"context"
"fmt"
"path/filepath"
"testing"
"time"
@@ -9,7 +10,6 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
@@ -272,35 +272,22 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
}
// deliverToArchive gives the setup's webhook a database target,
// runs one delivery to it through the running engine, and returns
// the target's ID and archive file path. The archive writer holds
// the file open afterwards.
func deliverToArchive(t *testing.T, s iSetup) (string, string) {
// deliverToArchive runs one delivery to a database target through
// the running engine and returns the webhook's archive file path.
// The archive writer holds the file open afterwards.
func deliverToArchive(t *testing.T, s iSetup) string {
t.Helper()
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
tgt := &database.Target{
WebhookID: s.WebhookID,
Name: "archive",
Type: database.TargetTypeDatabase,
}
require.NoError(
t, s.MainDB.Omit(clause.Associations).Create(tgt).Error,
)
deliveryID, task := seedLogTask(t, s)
task.TargetID = tgt.ID
task.TargetType = database.TargetTypeDatabase
s.Engine.Notify([]delivery.Task{task})
iWaitForDelivered(t, s.WebhookDB, deliveryID)
return tgt.ID, filepath.Join(
return filepath.Join(
filepath.Dir(s.DBMgr.DBPath(s.WebhookID)),
"archive-hook-archive-"+tgt.ID+".db",
fmt.Sprintf("archive-%s.db", s.WebhookID),
)
}
@@ -317,7 +304,7 @@ func TestEngine_StopHookClosesArchives(t *testing.T) {
lc := startEngineViaHook(t, s.Engine)
_, path := deliverToArchive(t, s)
path := deliverToArchive(t, s)
require.FileExists(
t, path+"-wal",
"an open archive should have a -wal for the stop to remove",
@@ -351,7 +338,7 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
lc := startEngineViaHook(t, s.Engine)
targetID, _ := deliverToArchive(t, s)
deliverToArchive(t, s)
release := make(chan struct{})
@@ -365,7 +352,7 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
require.True(
t, s.Engine.ExportArchiveHandleOpen(targetID),
t, s.Engine.ExportArchiveHandleOpen(s.WebhookID),
"a stop that timed out must not close archive writers",
)
}
+29 -16
Visa fil
@@ -351,15 +351,23 @@ func TestDeliverDatabase_ImmediateSuccess(
db := testWebhookDB(t)
// The database target archives for real, so the engine needs
// the target in the main database and a data directory.
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
// The database target archives for real now, so the engine
// needs a webhook DB manager to locate the data directory.
e := delivery.NewTestEngineWithDB(
nil,
database.NewTestWebhookDBManager(t.TempDir()),
slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
)),
&http.Client{Timeout: 5 * time.Second},
1,
)
event := seedEvent(t, db, `{"db":"target"}`)
d := seedDatabaseTargetDelivery(t, db, event, tgt)
d := seedDatabaseTargetDelivery(t, db, event, "")
env.eng.ExportDeliverDatabase(db, d)
e.ExportDeliverDatabase(db, d)
var updated database.Delivery
@@ -1324,27 +1332,32 @@ func TestProcessDelivery_RoutesToCorrectHandler(
db := testWebhookDB(t)
// The database target archives for real, so the engine needs
// the target in the main database and a data directory.
env := setupArchiveTest(t)
archive := env.seedDatabaseTarget(t, "")
// The database target archives for real now, so the engine
// needs a webhook DB manager to locate the data directory.
e := delivery.NewTestEngineWithDB(
nil,
database.NewTestWebhookDBManager(t.TempDir()),
slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
)),
&http.Client{Timeout: 5 * time.Second},
1,
)
tests := []struct {
name string
targetType database.TargetType
targetID string
wantStatus database.DeliveryStatus
}{
{
"database target",
database.TargetTypeDatabase,
archive.ID,
database.DeliveryStatusDelivered,
},
{
"log target",
database.TargetTypeLog,
uuid.New().String(),
database.DeliveryStatusDelivered,
},
}
@@ -1354,7 +1367,7 @@ func TestProcessDelivery_RoutesToCorrectHandler(
t.Parallel()
runRoutingSubtest(
t, db, env.eng, tt.targetType, tt.targetID,
t, db, e, tt.targetType,
tt.wantStatus,
)
})
@@ -1366,7 +1379,6 @@ func runRoutingSubtest(
db *gorm.DB,
e *delivery.Engine,
targetType database.TargetType,
targetID string,
wantStatus database.DeliveryStatus,
) {
t.Helper()
@@ -1374,7 +1386,8 @@ func runRoutingSubtest(
event := seedEvent(t, db, `{"routing":"test"}`)
dlv := seedDelivery(
t, db, event.ID, targetID,
t, db, event.ID,
uuid.New().String(),
database.DeliveryStatusPending,
)
+20 -26
Visa fil
@@ -474,7 +474,7 @@ func NewTestCircuitBreaker(
type ExportArchivedEvent = archivedEvent
// ExportArchiveWriter wraps an archiveWriter so black-box tests
// can exercise the archive file mechanics.
// can exercise the per-webhook archive file mechanics.
type ExportArchiveWriter struct {
w *archiveWriter
}
@@ -549,12 +549,6 @@ func (e *ExportArchiveWriter) Evict() {
e.w.evict()
}
// Rename gives the archive file a new name in the same directory,
// as a rename of the webhook or target does.
func (e *ExportArchiveWriter) Rename(name string) error {
return e.w.rename(name)
}
// HandleOpen reports whether the writer currently holds an open
// archive handle.
func (e *ExportArchiveWriter) HandleOpen() bool {
@@ -574,16 +568,16 @@ func (e *ExportArchiveWriter) Same(
}
// ExportArchiveWriterFor returns the archive writer the registry
// currently caches for a database target, or nil when none is
// cached. It never creates one, so a test can hold a reference to
// the very writer an eviction is about to detach.
// currently caches for a webhook, or nil when none is cached. It
// never creates one, so a test can hold a reference to the very
// writer an eviction is about to detach.
func (e *Engine) ExportArchiveWriterFor(
targetID string,
webhookID string,
) *ExportArchiveWriter {
e.dbTarget.mu.Lock()
defer e.dbTarget.mu.Unlock()
w, ok := e.dbTarget.writers[targetID]
w, ok := e.dbTarget.writers[webhookID]
if !ok {
return nil
}
@@ -592,26 +586,26 @@ func (e *Engine) ExportArchiveWriterFor(
}
// ExportHasArchiveWriter reports whether the database target
// type currently caches an archive writer for a target.
// currently caches an archive writer for a webhook.
func (e *Engine) ExportHasArchiveWriter(
targetID string,
webhookID string,
) bool {
e.dbTarget.mu.Lock()
defer e.dbTarget.mu.Unlock()
_, ok := e.dbTarget.writers[targetID]
_, ok := e.dbTarget.writers[webhookID]
return ok
}
// ExportArchiveHandleOpen reports whether the cached archive
// writer for a target holds an open database handle. It
// writer for a webhook holds an open database handle. It
// returns false when no writer is cached.
func (e *Engine) ExportArchiveHandleOpen(
targetID string,
webhookID string,
) bool {
e.dbTarget.mu.Lock()
w, ok := e.dbTarget.writers[targetID]
w, ok := e.dbTarget.writers[webhookID]
e.dbTarget.mu.Unlock()
if !ok {
@@ -625,12 +619,12 @@ func (e *Engine) ExportArchiveHandleOpen(
}
// ExportEnsureArchiveWriter creates (if needed) and returns the
// archive file path of the cached writer for a target, so a
// archive file path of the cached writer for a webhook, so a
// test can prime the registry the way a delivery would.
func (e *Engine) ExportEnsureArchiveWriter(
targetID string,
webhookID string,
) (string, error) {
w, err := e.dbTarget.writerFor(targetID)
w, err := e.dbTarget.writerFor(webhookID)
if err != nil {
return "", err
}
@@ -638,14 +632,14 @@ func (e *Engine) ExportEnsureArchiveWriter(
return w.path, nil
}
// ExportSweepWriterFor takes a target's registry writer exactly
// ExportSweepWriterFor takes a webhook's registry writer exactly
// as the idle sweep does, reporting whether the sweep had to
// create the entry. It lets a test drive the registry through the
// sweep's own entry point instead of choreographing goroutines.
func (e *Engine) ExportSweepWriterFor(
targetID string,
webhookID string,
) (*ExportArchiveWriter, bool, error) {
w, created, err := e.dbTarget.sweepWriterFor(targetID)
w, created, err := e.dbTarget.sweepWriterFor(webhookID)
if err != nil {
return nil, false, err
}
@@ -656,9 +650,9 @@ func (e *Engine) ExportSweepWriterFor(
// ExportReleaseSweepWriter releases a sweep-created registry entry
// exactly as a finished sweep does.
func (e *Engine) ExportReleaseSweepWriter(
targetID string, w *ExportArchiveWriter,
webhookID string, w *ExportArchiveWriter,
) {
e.dbTarget.releaseSweepWriter(targetID, w.w)
e.dbTarget.releaseSweepWriter(webhookID, w.w)
}
// NewTestArchiveSweeper builds an ArchiveSweeper backed by the
+57 -13
Visa fil
@@ -37,8 +37,8 @@ var (
"blocked cloud metadata address",
)
errBlockedMetadata = errors.New(
"blocked link-local or cloud instance metadata " +
"address: ALLOWED_EGRESS_CIDRS cannot open it",
"blocked link-local, cloud instance metadata or " +
"unspecified address: ALLOWED_EGRESS_CIDRS cannot open it",
)
errInvalidScheme = errors.New(
"only http and https are allowed",
@@ -72,14 +72,17 @@ var blockedNetworks []*net.IPNet
var blockedPublicNetworks []*net.IPNet
// alwaysBlockedNetworks are the ranges no configuration can
// open: the link-local blocks and the cloud instance metadata
// endpoints that live outside them. Reaching one is credential
// or user-data theft rather than delivery to an internal
// service, so a supplied CIDR that covers such an address still
// leaves it blocked.
// open, so a supplied CIDR that covers one still leaves it
// blocked. An entry is here for one of two reasons: it is a
// metadata endpoint (the link-local blocks and the cloud
// instance metadata endpoints that live outside them), or it is
// an unspecified address. Reaching a metadata endpoint is
// credential or user-data theft rather than delivery to an
// internal service.
//
// Inclusion criterion — an address belongs here only if BOTH
// hold, and every entry below satisfies both:
// Inclusion criterion for metadata endpoints — one belongs here
// only if BOTH hold, and every metadata entry below satisfies
// both:
//
// 1. It is a fixed address assigned by the provider, or a
// range reserved by IANA — never one the operator chose.
@@ -90,8 +93,8 @@ var blockedPublicNetworks []*net.IPNet
// not cheaply rotated.
//
// Both halves are load-bearing, so use them to refuse a
// candidate and say why. An endpoint disclosing only the
// operator's own inventory (instance id, region, disks, NICs)
// metadata candidate and say why. An endpoint disclosing only
// the operator's own inventory (instance id, region, disks, NICs)
// fails (2): letting a delivery target reach the operator's own
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
// provide. But (2) is not "IAM credentials only" either —
@@ -112,6 +115,15 @@ var blockedPublicNetworks []*net.IPNet
// This is a criterion, not an enumeration of every metadata
// address in existence.
//
// The unspecified addresses 0.0.0.0 and :: are here for a
// separate reason: they disclose nothing, but no host can have
// either, and on Linux a connection to one reaches this host's
// own loopback. Listing them means an allowlist reaches loopback
// only through an entry that covers a loopback address
// (127.0.0.0/8, ::1/128, 0.0.0.0/0), never through one that
// covers only 0.0.0.0 or :: (0.0.0.0/8, for example). Nothing
// else lives at either address, so refusing them costs nothing.
//
// Every entry is either already in blockedNetworks — this list is
// what makes it unconditional — or an alternate encoding of
// 169.254.169.254 that Contains does not match against
@@ -131,23 +143,46 @@ var alwaysBlockedNetworks []*net.IPNet
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
func init() {
blockedNetworks = mustParseCIDRs([]string{
// IPv4 loopback.
"127.0.0.0/8",
// RFC 1918 private network.
"10.0.0.0/8",
// RFC 1918 private network.
"172.16.0.0/12",
// RFC 1918 private network.
"192.168.0.0/16",
// IPv4 link-local.
"169.254.0.0/16",
// "This network", holding the IPv4 unspecified address 0.0.0.0.
"0.0.0.0/8",
// Carrier-grade NAT shared address space.
"100.64.0.0/10",
// IETF protocol assignments.
"192.0.0.0/24",
// IPv4 documentation (TEST-NET-1).
"192.0.2.0/24",
// Benchmarking.
"198.18.0.0/15",
// IPv4 documentation (TEST-NET-2).
"198.51.100.0/24",
// IPv4 documentation (TEST-NET-3).
"203.0.113.0/24",
// IPv4 multicast.
"224.0.0.0/4",
// Reserved, including the broadcast address.
"240.0.0.0/4",
// IPv6 loopback.
"::1/128",
// IPv6 unspecified address.
"::/128",
// IPv6 unique local addresses.
"fc00::/7",
// IPv6 link-local.
"fe80::/10",
// IPv6 multicast.
"ff00::/8",
// IPv6 documentation.
"2001:db8::/32",
})
blockedPublicNetworks = mustParseCIDRs([]string{
@@ -207,6 +242,14 @@ func init() {
// allowlist from opening it.
"192.0.0.192/32",
// The unspecified addresses, each of which reaches this
// host's loopback on Linux.
//
// IPv4 unspecified address, inside the blocked 0.0.0.0/8.
"0.0.0.0/32",
// IPv6 unspecified address.
"::/128",
// 169.254.169.254 as an IPv4-compatible IPv6 address.
"::a9fe:a9fe/128",
// 169.254.169.254 behind the NAT64 well-known prefix.
@@ -343,8 +386,9 @@ func (g *Guard) allows(ip net.IP) bool {
// The order is the policy:
//
// 1. alwaysBlockedNetworks is refused before the allowlist is
// consulted, so no configured CIDR reaches link-local or a
// cloud metadata endpoint at a non-public address.
// consulted, so no configured CIDR reaches link-local, a
// cloud metadata endpoint at a non-public address, or an
// unspecified address.
// 2. The allowlist is consulted next, so a listed private
// network, or a listed public address on the default
// blocklist, becomes reachable.
+39 -11
Visa fil
@@ -168,12 +168,13 @@ func TestGuardAllowlist_UnlistedPrivateStillRefused(t *testing.T) {
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
// case: cloud instance metadata endpoints are credential theft
// rather than delivery to an internal service, so no allowlist
// reaches one. Every guard below names a CIDR that covers its
// target — including 0.0.0.0/0, ::/0, and the ordinary ULA and
// CGNAT blocks an operator would really list — and the address
// must stay refused anyway, on both the validation and the
// delivery path.
// rather than delivery to an internal service, and the
// unspecified addresses 0.0.0.0 and :: reach this host's loopback
// on Linux, so no allowlist reaches any of them. Every guard
// below names a CIDR that covers its target — including
// 0.0.0.0/0, ::/0, and the ordinary ULA and CGNAT blocks an
// operator would really list — and the address must stay
// refused anyway, on both the validation and the delivery path.
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
t.Parallel()
@@ -219,15 +220,17 @@ type metadataAlwaysRefusedCase struct {
}
// metadataAlwaysRefusedCases enumerates every unconditionally
// blocked address together with an allowlist entry that would
// otherwise reach it. Split by family of address only to stay
// under the function-length limit.
// blocked address (link-local, the cloud metadata endpoints and
// the unspecified addresses) together with an allowlist entry
// that would otherwise reach it. Split by family of address only
// to stay under the function-length limit.
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
cases := linkLocalRefusedCases()
cases = append(cases, ulaMetadataRefusedCases()...)
cases = append(cases, ipv4MetadataRefusedCases()...)
cases = append(cases, encodedMetadataRefusedCases()...)
return append(cases, encodedMetadataRefusedCases()...)
return append(cases, unspecifiedRefusedCases()...)
}
// linkLocalRefusedCases covers the link-local blocks, including
@@ -367,6 +370,23 @@ func encodedMetadataRefusedCases() []metadataAlwaysRefusedCase {
}
}
// unspecifiedRefusedCases covers the unspecified addresses, each
// of which reaches this host's loopback on Linux.
func unspecifiedRefusedCases() []metadataAlwaysRefusedCase {
return []metadataAlwaysRefusedCase{
{
name: "IPv4 unspecified address under 0.0.0.0/0",
allow: allowAllIPv4,
target: "http://0.0.0.0:8080/hook",
},
{
name: "IPv6 unspecified address under ::/0",
allow: allowAllIPv6,
target: "http://[::]:8080/hook",
},
}
}
// TestGuardAllowlist_PublicUnaffected asserts the allowlist does
// not narrow anything: public addresses were reachable before it
// existed and stay reachable, whether or not a list is set.
@@ -524,6 +544,10 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
// Oracle Cloud Classic metadata, inside the blocked
// 192.0.0.0/24.
"192.0.0.192/32",
// The IPv4 and IPv6 unspecified addresses, each of
// which reaches this host's loopback on Linux.
"0.0.0.0/32",
"::/128",
// 169.254.169.254 as an IPv4-compatible IPv6 address.
"::a9fe:a9fe/128",
// 169.254.169.254 behind the NAT64 well-known prefix.
@@ -556,7 +580,8 @@ func TestDefaultBlocklist_PinnedSet(t *testing.T) {
{cidr: "172.16.0.0/12", reopenable: true},
{cidr: "192.168.0.0/16", reopenable: true},
{cidr: linkLocalIPv4, reopenable: false},
{cidr: "0.0.0.0/8", reopenable: true},
// Its first address, 0.0.0.0, is in the unconditional set.
{cidr: "0.0.0.0/8", reopenable: false},
{cidr: "100.64.0.0/10", reopenable: true},
{cidr: "192.0.0.0/24", reopenable: true},
{cidr: "192.0.2.0/24", reopenable: true},
@@ -566,8 +591,11 @@ func TestDefaultBlocklist_PinnedSet(t *testing.T) {
{cidr: "224.0.0.0/4", reopenable: true},
{cidr: "240.0.0.0/4", reopenable: true},
{cidr: "::1/128", reopenable: true},
{cidr: "::/128", reopenable: false},
{cidr: "fc00::/7", reopenable: true},
{cidr: "fe80::/10", reopenable: false},
{cidr: "ff00::/8", reopenable: true},
{cidr: "2001:db8::/32", reopenable: true},
{cidr: "168.63.129.16/32", public: true, reopenable: true},
}
+36
Visa fil
@@ -101,6 +101,42 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
}
}
// TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation
// covers the unspecified addresses and the IPv6 multicast and
// documentation ranges: with no allowlist set, each is refused
// both when a target is created and when a delivery dials it.
func TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation(
t *testing.T,
) {
t.Parallel()
guard := delivery.NewTestGuard()
targets := []string{
// The unspecified addresses. On Linux a connection to
// either reaches this host's loopback.
"http://0.0.0.0:8080/hook",
"http://[::]:8080/hook",
// IPv6 multicast, all nodes.
"http://[ff02::1]/hook",
// IPv6 documentation.
"http://[2001:db8::1]/hook",
}
for _, target := range targets {
t.Run(target, func(t *testing.T) {
t.Parallel()
require.Error(t,
guard.ValidateTargetURL(context.Background(), target),
"%s must be refused at target creation", target,
)
assertDialRefused(t, guard, target)
})
}
}
func TestValidateTargetURL_Allowed(t *testing.T) {
t.Parallel()
+88 -195
Visa fil
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"path/filepath"
"strings"
"sync"
"time"
@@ -12,75 +11,22 @@ import (
"sneak.berlin/go/webhooker/internal/database"
)
// archiveNameMaxLen is how many characters of a webhook or target
// name an archive file name keeps.
const archiveNameMaxLen = 40
// databaseTarget is a no-retry target that archives the full
// inbound event into the target's own archive SQLite file, separate
// from the per-webhook event database. The event is already
// persisted in the per-webhook event DB by the time delivery runs;
// the database target additionally writes a durable long-term copy
// into the file ArchiveFileName names and then records a single
// attempt whose outcome reflects whether the archive write
// succeeded. See archiveWriter for the close/reopen, auto-recreate,
// and expiry semantics.
// databaseTarget is a no-retry target that archives the
// full inbound event into a per-webhook archive SQLite file,
// separate from the per-webhook event database. The event is
// already persisted in the per-webhook event DB by the time
// delivery runs; the database target additionally writes a
// durable long-term copy into archive-{webhookID}.db and then
// records a single attempt whose outcome reflects whether the
// archive write succeeded. See archiveWriter for the
// close/reopen, auto-recreate, and expiry semantics.
type databaseTarget struct {
eng *Engine
// writers holds one archive writer per database target, keyed
// by target ID.
mu sync.Mutex
writers map[string]*archiveWriter
}
// ArchiveFileName returns the file name of a database target's
// archive: archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, with both
// names passed through archiveNamePart. The target ID keeps the
// name unique when two targets' names come out the same.
func ArchiveFileName(webhookName, targetName, targetID string) string {
return "archive-" + archiveNamePart(webhookName) + "-" +
archiveNamePart(targetName) + "-" + targetID + ".db"
}
// archiveNamePart makes a webhook or target name safe to put in a
// file name. It is lowercased; ASCII letters and digits are kept,
// every other run of characters becomes a single "-", and no "-" is
// left at either end. It is cut to archiveNameMaxLen characters, and
// a name with nothing left is "unnamed".
func archiveNamePart(name string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(name) {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
dash = b.Len() > 0
continue
}
if dash {
b.WriteByte('-')
dash = false
}
b.WriteRune(r)
}
part := b.String()
if len(part) > archiveNameMaxLen {
part = strings.TrimRight(part[:archiveNameMaxLen], "-")
}
if part == "" {
return "unnamed"
}
return part
}
// Deliver implements Target. It archives the event, then
// records one successful attempt and marks the delivery
// delivered. An archiving error fails the delivery: the
@@ -146,7 +92,7 @@ func (t *databaseTarget) Deliver(
)
}
// archive writes the full event as a row into the target's
// archive writes the full event as a row into the webhook's
// archive database, honouring the optional per-target expiry
// parsed from the target config JSON.
func (t *databaseTarget) archive(d *database.Delivery) error {
@@ -160,7 +106,7 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
return err
}
w, err := t.writerFor(d.TargetID)
w, err := t.writerFor(webhookID)
if err != nil {
return err
}
@@ -178,31 +124,30 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
return w.write(row, expiry)
}
// writerFor returns the archive writer for a database target,
// creating and caching it on first use. Each target has one writer
// so its close/reopen debounce state is shared across concurrent
// deliveries, and so a rename and the idle sweep take the same lock
// as its writes.
// writerFor returns the archiveWriter for a webhook, creating
// and caching it on first use. Each webhook has one writer so
// its close/reopen debounce state is shared across concurrent
// deliveries. The archive file lives beside the per-webhook
// event database in the data directory.
func (t *databaseTarget) writerFor(
targetID string,
webhookID string,
) (*archiveWriter, error) {
t.mu.Lock()
defer t.mu.Unlock()
w, ok := t.writers[targetID]
if !ok {
var err error
w, err = t.newWriter(targetID)
path, err := t.archivePath(webhookID)
if err != nil {
return nil, err
}
t.mu.Lock()
defer t.mu.Unlock()
if t.writers == nil {
t.writers = make(map[string]*archiveWriter)
}
t.writers[targetID] = w
w, ok := t.writers[webhookID]
if !ok {
w = newArchiveWriter(path, t.eng.log)
t.writers[webhookID] = w
}
// A delivery claims the entry: even if the idle sweep created
@@ -214,39 +159,40 @@ func (t *databaseTarget) writerFor(
}
// sweepWriterFor returns the archive writer the idle sweep should
// prune a target's archive through, together with whether the sweep
// itself created the registry entry.
// prune a webhook through, together with whether the sweep itself
// created the registry entry.
//
// The sweep must route its prune through the registered writer so
// the writer's mutex orders it against concurrent writes, but it
// must never leave a registry entry behind: a sweep that ran
// concurrently with the target's deletion would otherwise
// concurrently with the webhook's deletion would otherwise
// re-create an entry that nothing will ever evict again, which is
// exactly the leak eviction exists to prevent. An entry the sweep
// creates is therefore marked sweep-owned and handed back to
// releaseSweepWriter when the sweep is done.
func (t *databaseTarget) sweepWriterFor(
targetID string,
webhookID string,
) (*archiveWriter, bool, error) {
t.mu.Lock()
defer t.mu.Unlock()
w, ok := t.writers[targetID]
if ok {
return w, false, nil
}
w, err := t.newWriter(targetID)
path, err := t.archivePath(webhookID)
if err != nil {
return nil, false, err
}
t.mu.Lock()
defer t.mu.Unlock()
if t.writers == nil {
t.writers = make(map[string]*archiveWriter)
}
w, ok := t.writers[webhookID]
if ok {
return w, false, nil
}
w = newArchiveWriter(path, t.eng.log)
w.sweepOwned = true
t.writers[targetID] = w
t.writers[webhookID] = w
return w, true, nil
}
@@ -263,95 +209,57 @@ func (t *databaseTarget) sweepWriterFor(
// delivery that adopted the writer keeps a registered, evictable
// one.
func (t *databaseTarget) releaseSweepWriter(
targetID string, w *archiveWriter,
webhookID string, w *archiveWriter,
) {
t.mu.Lock()
defer t.mu.Unlock()
cur, ok := t.writers[targetID]
cur, ok := t.writers[webhookID]
if !ok || cur != w || !cur.sweepOwned {
return
}
delete(t.writers, targetID)
delete(t.writers, webhookID)
}
// newWriter builds the writer for a database target's archive. The
// file lives beside the webhook's event database in the data
// directory and is named for the webhook and the target as the main
// database has them now; from then on only rename changes the name
// the writer uses. It does not touch the archive file.
func (t *databaseTarget) newWriter(
targetID string,
) (*archiveWriter, error) {
// archivePath returns the archive file path for a webhook: it
// lives beside the per-webhook event database in the data
// directory. It does not touch the filesystem.
func (t *databaseTarget) archivePath(
webhookID string,
) (string, error) {
if t.eng.dbManager == nil {
return nil, errArchiveNoDataDir
return "", errArchiveNoDataDir
}
var target database.Target
dir := filepath.Dir(t.eng.dbManager.DBPath(webhookID))
err := t.eng.database.DB().
Preload("Webhook").
First(&target, "id = ?", targetID).Error
if err != nil {
return nil, fmt.Errorf(
"loading database target %s: %w", targetID, err,
)
return filepath.Join(
dir, fmt.Sprintf("archive-%s.db", webhookID),
), nil
}
dir := filepath.Dir(t.eng.dbManager.DBPath(target.WebhookID))
name := ArchiveFileName(
target.Webhook.Name, target.Name, target.ID,
)
w := newArchiveWriter(filepath.Join(dir, name), t.eng.log)
w.webhookID = target.WebhookID
return w, nil
}
// rename moves a database target's archive file to the name for
// webhookName and targetName. It goes through the target's writer,
// so the move holds the lock that writes and the idle sweep take,
// and later writes use the new name.
//
// The writer is created if there is none, and it stays cached. The
// handlers rename before they save the new name, so until the save
// the main database still has the old one; a delivery in that window
// must find this writer rather than build one from the old name.
func (t *databaseTarget) rename(
targetID, webhookName, targetName string,
) error {
w, err := t.writerFor(targetID)
if err != nil {
return err
}
return w.rename(ArchiveFileName(webhookName, targetName, targetID))
}
// evict drops a database target's archive writer from the registry
// and closes its handle, so a deleted target does not leave a
// writer (and an open archive handle within its debounce window)
// alive for the process lifetime.
// evict drops a webhook's archive writer from the registry and
// closes its handle, so a deleted webhook does not leave a
// writer (and an open archive handle within its debounce
// window) alive for the process lifetime.
//
// The map entry is removed under the registry lock, which is
// then released before the handle is closed under the writer's
// own lock: that ordering keeps the registry available to other
// targets while an in-flight write on this one drains, and
// webhooks while an in-flight write on this one drains, and
// closing under the writer's lock means eviction can never race
// a write.
//
// Eviction is idempotent and silent for a target with no writer,
// which is the common case: only a database target that has
// received an event or been renamed has one. It never deletes the
// archive file.
func (t *databaseTarget) evict(targetID string) {
// Eviction is idempotent and silent for a webhook with no
// writer, which is the common case: a webhook with no database
// target never creates one. It never deletes the archive file.
func (t *databaseTarget) evict(webhookID string) {
t.mu.Lock()
w, ok := t.writers[targetID]
w, ok := t.writers[webhookID]
if ok {
delete(t.writers, targetID)
delete(t.writers, webhookID)
}
t.mu.Unlock()
@@ -362,43 +270,15 @@ func (t *databaseTarget) evict(targetID string) {
w.evict()
t.eng.log.Info(
"evicted archive writer",
"target_id", targetID,
"path", w.path,
)
}
// evictWebhook evicts, exactly as evict does, the writer of every
// database target of a webhook.
func (t *databaseTarget) evictWebhook(webhookID string) {
t.mu.Lock()
var gone []*archiveWriter
for targetID, w := range t.writers {
if w.webhookID == webhookID {
delete(t.writers, targetID)
gone = append(gone, w)
}
}
t.mu.Unlock()
for _, w := range gone {
w.evict()
t.eng.log.Info(
"evicted archive writer",
"webhook_id", webhookID,
"path", w.path,
)
}
}
// evictAll evicts every cached archive writer, exactly as evict
// does for one target. The engine calls it at shutdown, once its
// does for one webhook. The engine calls it at shutdown, once its
// workers have returned. Closing the last handle on an archive
// moves the contents of its -wal into the .db and removes the
// -wal, so a clean stop leaves each archive as a single file.
@@ -415,25 +295,38 @@ func (t *databaseTarget) evictAll() {
}
}
// sweepArchive prunes one database target's archive of rows older
// than expiry, without requiring a write. A missing archive file is
// left missing (see sweepExpired), so a sweep never creates an
// archive for a target that has never received an event.
// sweepWebhook prunes one webhook's archive of rows older than
// expiry, without requiring a write. It returns nil (nothing to
// do) when the archive file does not exist, so a sweep never
// creates an archive for a webhook that has a database target
// but has never received an event.
//
// It also never leaves a registry entry behind: an entry it had
// to create to reach the writer's mutex is released again once
// the prune is done, so a sweep racing a target deletion cannot
// the prune is done, so a sweep racing a webhook deletion cannot
// resurrect the writer the eviction just dropped.
func (t *databaseTarget) sweepArchive(
targetID string, expiry time.Duration,
func (t *databaseTarget) sweepWebhook(
webhookID string, expiry time.Duration,
) error {
w, created, err := t.sweepWriterFor(targetID)
path, err := t.archivePath(webhookID)
if err != nil {
return err
}
// Check before taking a writer at all: a webhook whose
// archive has never been created gets no writer, no handle,
// and no file.
if !fileExists(path) {
return nil
}
w, created, err := t.sweepWriterFor(webhookID)
if err != nil {
return err
}
if created {
defer t.releaseSweepWriter(targetID, w)
defer t.releaseSweepWriter(webhookID, w)
}
return w.sweepExpired(expiry)
+17 -97
Visa fil
@@ -4,10 +4,8 @@ import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
@@ -43,7 +41,7 @@ const (
var (
// errArchiveMissingWebhookID is returned when an event to
// archive has no webhook id to record in its archive row.
// archive has no webhook id to key its archive file on.
errArchiveMissingWebhookID = errors.New(
"cannot archive event without a webhook id",
)
@@ -63,19 +61,13 @@ var (
)
// errArchiveWriterEvicted is returned when a writer that has
// been evicted (its target or its webhook was deleted) is used
// again. An evicted writer is no longer in the registry, so
// reopening its file would leak a handle nothing owns.
// been evicted (its webhook was deleted, or its last database
// target was removed) is used again. An evicted writer is no
// longer in the registry, so reopening its file would leak a
// handle nothing owns.
errArchiveWriterEvicted = errors.New(
"archive writer has been evicted",
)
// ErrArchiveNameTaken is returned when an archive cannot be
// renamed because a file already has the new name. That file may
// be an archive with rows of its own, so it is never replaced.
ErrArchiveNameTaken = errors.New(
"a file already has the archive's new name",
)
)
// databaseTargetConfig is the optional per-target JSON config
@@ -88,7 +80,7 @@ type databaseTargetConfig struct {
}
// archivedEvent is one fully captured webhook event stored in a
// database target's archive for long-term retention. It is a
// per-webhook archive database for long-term retention. It is a
// self-contained copy — independent of the per-webhook event
// database, which may prune events under its own retention.
type archivedEvent struct {
@@ -178,8 +170,8 @@ func ValidateArchiveExpiry(expiry string) error {
return nil
}
// archiveWriter owns one database target's archive SQLite file.
// It serialises writes, and after each write closes and reopens
// archiveWriter owns one per-webhook archive SQLite file. It
// serialises writes, and after each write closes and reopens
// the file (debounced to at most once per debounce window) so
// an operator can move the file away for offline archiving. The
// next write recreates a moved or removed file, because the
@@ -195,21 +187,16 @@ type archiveWriter struct {
reopens int
// evicted marks a writer that has been removed from the
// registry. Its handle is closed and it must never open the
// file again: nothing holds it any more, so a reopen would
// leak the handle for the process lifetime.
// per-webhook registry. Its handle is closed and it must
// never open the file again: nothing holds it any more, so a
// reopen would leak the handle for the process lifetime.
evicted bool
// webhookID is the webhook the archive's target belongs to,
// so deleting the webhook can find its writers. It is set
// when the writer is created and never changes.
webhookID string
// sweepOwned marks a registry entry that the idle sweep
// created because no writer was cached for the target. The
// created because no writer was cached for the webhook. The
// sweep removes such an entry again when it is done, so a
// sweep can never leave — or resurrect — a registry entry
// for a target that has been deleted. A delivery that adopts
// for a webhook that has been deleted. A delivery that adopts
// the writer clears the flag, handing the entry to the
// registry proper.
//
@@ -398,78 +385,11 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
return nil
}
// rename gives the archive file a new name in the same directory,
// and the writer uses the file under that name from now on. The
// handle is closed first, which folds the -wal into the .db; any
// -wal or -shm still beside the file (left by a crash) is moved with
// it, because SQLite finds them by name. A missing file is not an
// error: the operator may have moved it away, and the next write
// creates it under the new name.
//
// If a file already has the new name, nothing is moved and the
// error is ErrArchiveNameTaken. If one file fails to move, those
// already moved are moved back before the error is returned, so the
// archive is never split across two names.
func (w *archiveWriter) rename(name string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.evicted {
return fmt.Errorf(
"%w: %s", errArchiveWriterEvicted, w.path,
)
}
path := filepath.Join(filepath.Dir(w.path), name)
if path == w.path {
return nil
}
suffixes := []string{"", "-wal", "-shm"}
for _, suffix := range suffixes {
if fileExists(path + suffix) {
return fmt.Errorf(
"%w: %s", ErrArchiveNameTaken, name+suffix,
)
}
}
w.close()
for i, suffix := range suffixes {
err := os.Rename(w.path+suffix, path+suffix)
if err == nil || errors.Is(err, fs.ErrNotExist) {
continue
}
for _, moved := range suffixes[:i] {
backErr := os.Rename(path+moved, w.path+moved)
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
w.log.Error(
"failed to move archive file back",
"from", path+moved,
"to", w.path+moved,
"error", backErr,
)
}
}
return fmt.Errorf(
"renaming archive %s to %s: %w", w.path+suffix, path+suffix, err,
)
}
w.path = path
return nil
}
// evict closes the writer's handle and marks it unusable. It is
// called when the writer leaves the registry, because its target
// or its webhook was deleted, or at shutdown. The archive FILE is
// deliberately left on disk: it is long-term storage an operator
// may still want.
// called when the writer leaves the registry, either because the
// webhook was deleted or because its last database target was
// removed. The archive FILE is deliberately left on disk: it is
// long-term storage an operator may still want.
func (w *archiveWriter) evict() {
w.mu.Lock()
defer w.mu.Unlock()
+77 -87
Visa fil
@@ -17,109 +17,85 @@ import (
"sneak.berlin/go/webhooker/internal/delivery"
)
// deliverTo archives one event to a database target, leaving the
// target's writer cached with its handle open.
func deliverTo(
t *testing.T, env *archiveEnv, tgt *database.Target,
) {
// evictTestEngine builds an engine backed by a temporary data
// directory and returns it along with that directory.
func evictTestEngine(t *testing.T) (*delivery.Engine, string) {
t.Helper()
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
dataDir := t.TempDir()
env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
eng := delivery.NewTestEngineWithDB(
nil,
database.NewTestWebhookDBManager(dataDir),
archiveTestLogger(),
&http.Client{Timeout: 5 * time.Second},
1,
)
return eng, dataDir
}
// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting
// a webhook drops the archive writers of its database targets
// from the registry and closes their open handles, rather than
// leaving them alive for the process lifetime, and leaves another
// webhook's writer alone.
// a webhook drops its archive writer from the registry and
// closes the open archive handle, rather than leaving both
// alive for the process lifetime.
func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
first := env.seedDatabaseTarget(t, "")
second := env.addDatabaseTarget(t, first.WebhookID, "")
other := env.seedDatabaseTarget(t, "")
eng, dataDir := evictTestEngine(t)
for _, tgt := range []*database.Target{first, second, other} {
deliverTo(t, env, tgt)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
eng.ExportDeliverDatabase(webhookDB, d)
webhookID := event.WebhookID
require.True(
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
t, eng.ExportHasArchiveWriter(webhookID),
"a delivery should have cached an archive writer",
)
require.True(
t, eng.ExportArchiveHandleOpen(webhookID),
"the writer should hold an open handle after a write",
)
}
env.eng.EvictWebhook(first.WebhookID)
eng.EvictWebhook(webhookID)
for _, tgt := range []*database.Target{first, second} {
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, eng.ExportHasArchiveWriter(webhookID),
"eviction should remove the registry entry",
)
assert.False(
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
t, eng.ExportArchiveHandleOpen(webhookID),
"eviction should close the archive handle",
)
archivePath := filepath.Join(
dataDir, fmt.Sprintf("archive-%s.db", webhookID),
)
assert.FileExists(
t, env.archivePath(tgt),
t, archivePath,
"eviction must not delete the archive file",
)
}
assert.True(
t, env.eng.ExportArchiveHandleOpen(other.ID),
"another webhook's writer must be left alone",
)
}
// TestEvictTarget_LeavesOtherTargets proves that evicting one
// database target leaves the writer of another target of the same
// webhook in place.
func TestEvictTarget_LeavesOtherTargets(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
doomed := env.seedDatabaseTarget(t, "")
kept := env.addDatabaseTarget(t, doomed.WebhookID, "")
deliverTo(t, env, doomed)
deliverTo(t, env, kept)
env.eng.EvictTarget(doomed.ID)
assert.False(t, env.eng.ExportHasArchiveWriter(doomed.ID))
assert.FileExists(
t, env.archivePath(doomed),
"eviction must not delete the archive file",
)
assert.True(
t, env.eng.ExportArchiveHandleOpen(kept.ID),
"the other target's writer must be left alone",
)
}
// TestEvictWebhook_UnknownWebhookIsNoOp proves eviction is safe
// for the common case of a webhook or target that never had an
// archive writer, and that repeating it does not panic.
// for the common case of a webhook that never had a database
// target, and that repeating it does not panic.
func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
eng, _ := evictTestEngine(t)
assert.NotPanics(t, func() {
env.eng.EvictWebhook("no-such-webhook")
env.eng.EvictWebhook("no-such-webhook")
env.eng.EvictTarget("no-such-target")
env.eng.EvictTarget("no-such-target")
eng.EvictWebhook("no-such-webhook")
eng.EvictWebhook("no-such-webhook")
})
assert.False(
t, env.eng.ExportHasArchiveWriter("no-such-target"),
t, eng.ExportHasArchiveWriter("no-such-webhook"),
"eviction must not create a writer",
)
}
@@ -313,14 +289,17 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
eng, _ := evictTestEngine(t)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
// Prime the registry so the test can hold the very writer the
// eviction is about to detach.
deliverTo(t, env, tgt)
eng.ExportDeliverDatabase(webhookDB, d)
w := env.eng.ExportArchiveWriterFor(tgt.ID)
w := eng.ExportArchiveWriterFor(event.WebhookID)
require.NotNil(t, w)
require.True(t, w.HandleOpen())
@@ -330,7 +309,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
// eviction has to contend for the writer's mutex.
race.awaitFirstWrite()
env.eng.EvictWebhook(tgt.WebhookID)
eng.EvictWebhook(event.WebhookID)
sawEvicted, otherErr := race.wait()
@@ -345,33 +324,41 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
"been evicted",
)
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, eng.ExportHasArchiveWriter(event.WebhookID),
"the registry entry must stay gone",
)
}
// TestEvictWebhook_LaterDeliveryRecreatesWriter proves eviction
// does not break archiving for a target that is still alive: a
// does not break archiving for a webhook that is still alive: a
// subsequent delivery gets a brand new writer from the registry.
// It says nothing about the evicted writer itself — that is what
// TestEvictedWriter_WriteDoesNotReopenFile covers.
func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
eng, _ := evictTestEngine(t)
deliverTo(t, env, tgt)
require.True(t, env.eng.ExportHasArchiveWriter(tgt.ID))
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
env.eng.EvictWebhook(tgt.WebhookID)
eng.ExportDeliverDatabase(webhookDB, d)
require.True(
t, eng.ExportHasArchiveWriter(event.WebhookID),
)
// A fresh delivery for the same target gets a brand new
eng.EvictWebhook(event.WebhookID)
// A fresh delivery for the same webhook gets a brand new
// writer from the registry, so archiving keeps working.
deliverTo(t, env, tgt)
second := seedDatabaseTargetDelivery(
t, webhookDB, event, "",
)
eng.ExportDeliverDatabase(webhookDB, second)
assert.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, eng.ExportHasArchiveWriter(event.WebhookID),
"a later delivery should recreate the writer",
)
}
@@ -383,16 +370,19 @@ func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
eng, _ := evictTestEngine(t)
deliverTo(t, env, tgt)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
w := env.eng.ExportArchiveWriterFor(tgt.ID)
eng.ExportDeliverDatabase(webhookDB, d)
w := eng.ExportArchiveWriterFor(event.WebhookID)
require.NotNil(t, w)
require.True(t, w.HandleOpen())
require.NoError(t, env.eng.ExportStop(context.Background()))
require.NoError(t, eng.ExportStop(context.Background()))
err := w.Write(evictTestRow("ev-after-stop"), 0)
@@ -405,7 +395,7 @@ func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
"a refused write must not reopen the archive",
)
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
t, eng.ExportHasArchiveWriter(event.WebhookID),
"the stop should empty the registry",
)
+40 -307
Visa fil
@@ -4,12 +4,13 @@ import (
"database/sql"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
@@ -73,18 +74,25 @@ func removeArchiveFiles(t *testing.T, path string) {
// TestDeliverDatabase_ArchivesEvent verifies that delivering to
// a database target marks the delivery delivered and archives
// the full event into the target's own archive file.
// the full event into a separate per-webhook archive file.
func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
dataDir := t.TempDir()
dbMgr := database.NewTestWebhookDBManager(dataDir)
e := delivery.NewTestEngineWithDB(
nil, dbMgr,
archiveTestLogger(),
&http.Client{Timeout: 5 * time.Second},
1,
)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
env.eng.ExportDeliverDatabase(webhookDB, d)
e.ExportDeliverDatabase(webhookDB, d)
var updated database.Delivery
@@ -97,7 +105,8 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
)
archivePath := filepath.Join(
env.dataDir, "archive-sweep-test-archive-"+tgt.ID+".db",
dataDir,
fmt.Sprintf("archive-%s.db", event.WebhookID),
)
assert.FileExists(t, archivePath)
@@ -279,31 +288,31 @@ func TestParseArchiveExpiry(t *testing.T) {
}
}
// seedDatabaseTargetDelivery seeds a pending delivery of an event
// to a database target and returns the in-memory delivery the
// target handler is invoked with.
// seedDatabaseTargetDelivery seeds a pending delivery for a
// database target with the given config JSON and returns the
// in-memory delivery the target handler is invoked with.
func seedDatabaseTargetDelivery(
t *testing.T,
webhookDB *gorm.DB,
event database.Event,
tgt *database.Target,
config string,
) *database.Delivery {
t.Helper()
dlv := seedDelivery(
t, webhookDB, event.ID, tgt.ID,
t, webhookDB, event.ID, uuid.New().String(),
database.DeliveryStatusPending,
)
d := &database.Delivery{
EventID: event.ID,
TargetID: tgt.ID,
TargetID: dlv.TargetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: tgt.Name,
Name: "test-db",
Type: database.TargetTypeDatabase,
Config: tgt.Config,
Config: config,
},
}
d.ID = dlv.ID
@@ -321,14 +330,22 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"nonsense"}`)
dataDir := t.TempDir()
e := delivery.NewTestEngineWithDB(
nil, database.NewTestWebhookDBManager(dataDir),
archiveTestLogger(),
&http.Client{Timeout: 5 * time.Second},
1,
)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":false}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
d := seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"nonsense"}`,
)
env.eng.ExportDeliverDatabase(webhookDB, d)
e.ExportDeliverDatabase(webhookDB, d)
var updated database.Delivery
@@ -356,7 +373,10 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
)
assert.NoFileExists(t,
env.archivePath(tgt),
filepath.Join(
dataDir,
fmt.Sprintf("archive-%s.db", event.WebhookID),
),
"no archive file should exist for a failed config",
)
}
@@ -380,290 +400,3 @@ func TestValidateArchiveExpiry(t *testing.T) {
)
}
}
// TestArchiveFileName pins the archive file name and the rules
// that make a webhook or target name safe to put in it.
func TestArchiveFileName(t *testing.T) {
t.Parallel()
const id = "3f2a1c9e-8d4b-4c1a-9e2f-0a1b2c3d4e5f"
cases := []struct {
name string
webhook string
target string
want string
}{
{
"plain names", "orders", "archive",
"archive-orders-archive-" + id + ".db",
},
{
"lowercased", "Orders", "Main Archive",
"archive-orders-main-archive-" + id + ".db",
},
{
"a run of other characters is one dash",
`a /\..b`, "c__--d",
"archive-a-b-c-d-" + id + ".db",
},
{
"no dash at either end", " --orders!! ", "(archive)",
"archive-orders-archive-" + id + ".db",
},
{
"path separators", "../../etc/passwd", "a/b",
"archive-etc-passwd-a-b-" + id + ".db",
},
{
"letters outside ASCII are dropped",
"Bestellungen Größe", "café",
"archive-bestellungen-gr-e-caf-" + id + ".db",
},
{
"nothing left is unnamed", "", "!!!",
"archive-unnamed-unnamed-" + id + ".db",
},
{
"cut to 40 characters", strings.Repeat("a", 50), "x",
"archive-" + strings.Repeat("a", 40) + "-x-" + id + ".db",
},
{
"no dash left by the cut",
strings.Repeat("a", 39) + " b", "x",
"archive-" + strings.Repeat("a", 39) + "-x-" + id + ".db",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(
t, tc.want,
delivery.ArchiveFileName(tc.webhook, tc.target, id),
)
})
}
}
// TestDeliverDatabase_EachTargetHasItsOwnArchive proves two
// database targets of one webhook archive into separate files.
func TestDeliverDatabase_EachTargetHasItsOwnArchive(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
first := env.seedDatabaseTarget(t, "")
second := env.addDatabaseTarget(t, first.WebhookID, "")
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
for _, tgt := range []*database.Target{first, second} {
env.eng.ExportDeliverDatabase(
webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
)
}
require.NotEqual(
t, env.archivePath(first), env.archivePath(second),
)
assert.Equal(
t, []string{event.ID},
archivedEventIDs(t, env.archivePath(first)),
)
assert.Equal(
t, []string{event.ID},
archivedEventIDs(t, env.archivePath(second)),
)
}
// TestRename_MovesTheFile proves a rename moves the archive, rows
// and all, and that later writes go to the new name.
func TestRename_MovesTheFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
oldPath := env.archivePath(tgt)
webhookDB := testWebhookDB(t)
first := seedEvent(t, webhookDB, `{"n":1}`)
env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
)
require.FileExists(t, oldPath)
require.NoError(
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
)
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
assert.NoFileExists(t, oldPath)
assert.Equal(t, []string{first.ID}, archivedEventIDs(t, newPath))
second := seedEvent(t, webhookDB, `{"n":2}`)
env.eng.ExportDeliverDatabase(
webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
)
assert.ElementsMatch(
t, []string{first.ID, second.ID},
archivedEventIDs(t, newPath),
)
assert.NoFileExists(
t, oldPath, "a write after the rename must use the new name",
)
}
// TestRename_NeverReplacesAFile plants a file at the new name, once
// the .db alone, once a lone -wal and once a lone -shm, and proves
// each time that the rename is refused, the planted file survives,
// and the archive keeps its name and its rows.
func TestRename_NeverReplacesAFile(t *testing.T) {
t.Parallel()
for _, suffix := range archiveFileSuffixes() {
t.Run("planted .db"+suffix, func(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
oldPath := env.archivePath(tgt)
webhookDB := testWebhookDB(t)
first := seedEvent(t, webhookDB, `{"n":1}`)
env.eng.ExportDeliverDatabase(
webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
)
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
plantedPath := newPath + suffix
require.NoError(
t, os.WriteFile(plantedPath, []byte("planted"), 0o600),
)
require.ErrorIs(
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
delivery.ErrArchiveNameTaken,
)
//nolint:gosec // reads the file the test planted under t.TempDir()
planted, err := os.ReadFile(plantedPath)
require.NoError(t, err)
assert.Equal(t, "planted", string(planted))
second := seedEvent(t, webhookDB, `{"n":2}`)
env.eng.ExportDeliverDatabase(
webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
)
assert.ElementsMatch(
t, []string{first.ID, second.ID},
archivedEventIDs(t, oldPath),
)
})
}
}
// TestRename_BeforeTheNameIsSaved covers the order the handlers
// use: they rename before they save the new name, so a delivery in
// between must write under the new name although the main database
// still has the old one. It also shows that renaming an archive that
// does not exist yet is not an error.
func TestRename_BeforeTheNameIsSaved(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
require.NoError(
t, env.eng.Rename(tgt.ID, "Orders", "Archive"),
)
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
)
assert.FileExists(
t,
filepath.Join(
env.dataDir, "archive-orders-archive-"+tgt.ID+".db",
),
)
assert.NoFileExists(t, env.archivePath(tgt))
}
// TestArchiveWriter_RenameMovesSidecars proves a rename carries
// the -wal and -shm a crash can leave beside an archive no handle
// has opened since. SQLite finds them by name, so a -wal left
// behind would lose the transactions it holds.
func TestArchiveWriter_RenameMovesSidecars(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
newPath := filepath.Join(dir, "archive-new.db")
for _, suffix := range archiveFileSuffixes() {
require.NoError(
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
)
}
w := delivery.NewExportArchiveWriter(
oldPath, archiveTestLogger(), 0,
)
require.NoError(t, w.Rename("archive-new.db"))
for _, suffix := range archiveFileSuffixes() {
assert.NoFileExists(t, oldPath+suffix)
assert.FileExists(t, newPath+suffix)
}
assert.Equal(t, newPath, w.Path())
}
// TestArchiveWriter_RenameMovesBackOnFailure makes the -wal fail to
// move after the .db has moved, and proves the .db is moved back, so
// the archive is never split across two names. The new name is 255
// bytes, the longest a file name may be, so the .db can take it but
// the -wal, four bytes longer, cannot.
func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
newName := strings.Repeat("a", 252) + ".db"
for _, suffix := range archiveFileSuffixes() {
require.NoError(
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
)
}
w := delivery.NewExportArchiveWriter(
oldPath, archiveTestLogger(), 0,
)
require.Error(t, w.Rename(newName))
for _, suffix := range archiveFileSuffixes() {
assert.FileExists(t, oldPath+suffix)
}
assert.NoFileExists(t, filepath.Join(dir, newName))
assert.Equal(t, oldPath, w.Path())
}
+4
Visa fil
@@ -137,6 +137,10 @@ func bootAtDebug(t *testing.T, dataDir string) string {
app := fxtest.New(
t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned.
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
+6 -3
Visa fil
@@ -14,6 +14,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals"
@@ -57,13 +58,14 @@ type HandlersParams struct {
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Database *database.Database
WebhookDBMgr *database.WebhookDBManager
Healthcheck *healthcheck.Healthcheck
Session *session.Session
Middleware *middleware.Middleware
Notifier delivery.Notifier
Archives delivery.Archives
Evictor delivery.WebhookEvictor
SSRFGuard *delivery.Guard
Metrics *metrics.Set
Registry *prometheus.Registry
@@ -80,7 +82,7 @@ type Handlers struct {
session *session.Session
mw *middleware.Middleware
notifier delivery.Notifier
archives delivery.Archives
evictor delivery.WebhookEvictor
mtr *metrics.Set
templates map[string]*template.Template
@@ -132,7 +134,7 @@ func New(
s.session = params.Session
s.mw = params.Middleware
s.notifier = params.Notifier
s.archives = params.Archives
s.evictor = params.Evictor
s.mtr = params.Metrics
s.ssrf = params.SSRFGuard
@@ -140,6 +142,7 @@ func New(
s.templates = map[string]*template.Template{
"login.html": parsePageTemplate("login.html"),
"profile.html": parsePageTemplate("profile.html"),
"settings.html": parsePageTemplate("settings.html"),
"sources_list.html": parsePageTemplate("sources_list.html"),
"sources_new.html": parsePageTemplate("sources_new.html"),
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
+27 -91
Visa fil
@@ -3,7 +3,6 @@ package handlers_test
import (
"context"
"errors"
"fmt"
"html/template"
"net/http"
"net/http/httptest"
@@ -53,77 +52,23 @@ func (n *recordingNotifier) Tasks() []delivery.Task {
return out
}
// recordingArchives is a delivery.Archives that records what it
// was asked to do, so a test can prove that a deletion or rename
// path reached the delivery engine. After FailRenames, every
// rename of that target fails with the given error.
type recordingArchives struct {
// recordingEvictor is a delivery.WebhookEvictor that records
// the webhook ids it was asked to evict, so a test can prove
// that a deletion path reached the delivery engine.
type recordingEvictor struct {
mu sync.Mutex
evicted []string
evictedTargets []string
renames []archiveRename
renameErrs map[string]error
}
// errInjectedRename is the failure a test hands FailRenames.
var errInjectedRename = errors.New("injected rename failure")
// errNameTaken is what the delivery engine returns when a file
// already has an archive's new name, here archive-taken.db.
var errNameTaken = fmt.Errorf(
"%w: archive-taken.db", delivery.ErrArchiveNameTaken,
)
// archiveRename is one recorded Rename call.
type archiveRename struct {
TargetID string
WebhookName string
TargetName string
}
func (r *recordingArchives) EvictWebhook(webhookID string) {
func (r *recordingEvictor) EvictWebhook(webhookID string) {
r.mu.Lock()
defer r.mu.Unlock()
r.evicted = append(r.evicted, webhookID)
}
func (r *recordingArchives) EvictTarget(targetID string) {
r.mu.Lock()
defer r.mu.Unlock()
r.evictedTargets = append(r.evictedTargets, targetID)
}
func (r *recordingArchives) Rename(
targetID, webhookName, targetName string,
) error {
r.mu.Lock()
defer r.mu.Unlock()
r.renames = append(r.renames, archiveRename{
TargetID: targetID,
WebhookName: webhookName,
TargetName: targetName,
})
return r.renameErrs[targetID]
}
// FailRenames makes every later rename of targetID fail with err.
func (r *recordingArchives) FailRenames(targetID string, err error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.renameErrs == nil {
r.renameErrs = map[string]error{}
}
r.renameErrs[targetID] = err
}
// Evicted returns a copy of the recorded webhook ids.
func (r *recordingArchives) Evicted() []string {
func (r *recordingEvictor) Evicted() []string {
r.mu.Lock()
defer r.mu.Unlock()
@@ -133,44 +78,35 @@ func (r *recordingArchives) Evicted() []string {
return out
}
// EvictedTargets returns a copy of the recorded target ids.
func (r *recordingArchives) EvictedTargets() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, len(r.evictedTargets))
copy(out, r.evictedTargets)
return out
}
// Renames returns a copy of the recorded renames.
func (r *recordingArchives) Renames() []archiveRename {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]archiveRename, len(r.renames))
copy(out, r.renames)
return out
}
func newTestApp(
t *testing.T,
targets ...any,
) *fxtest.App {
t.Helper()
return newTestAppWithConfig(
t, &config.Config{DataDir: t.TempDir()}, targets...,
)
}
// newTestAppWithConfig is newTestApp over a caller-supplied Config.
func newTestAppWithConfig(
t *testing.T,
cfg *config.Config,
targets ...any,
) *fxtest.App {
t.Helper()
return fxtest.New(
t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned.
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
func() *config.Config {
return &config.Config{
DataDir: t.TempDir(),
}
},
func() *config.Config { return cfg },
database.New,
database.NewWebhookDBManager,
healthcheck.New,
@@ -181,10 +117,10 @@ func newTestApp(
func(n *recordingNotifier) delivery.Notifier {
return n
},
func() *recordingArchives {
return &recordingArchives{}
func() *recordingEvictor {
return &recordingEvictor{}
},
func(r *recordingArchives) delivery.Archives {
func(r *recordingEvictor) delivery.WebhookEvictor {
return r
},
metrics.NewRegistry,
+135
Visa fil
@@ -0,0 +1,135 @@
package handlers
import (
"net/http"
"net/netip"
"strconv"
"strings"
"sneak.berlin/go/webhooker/internal/config"
)
// notSet is what the Settings page shows for a value that is empty.
const notSet = "not set"
// settingRow is one line of the Settings page: an environment
// variable, what it controls, and the value the server loaded for it.
type settingRow struct {
Name string
Description string
Value string
}
// HandleSettings returns a handler for the read-only Settings page,
// which lists the configuration the server started with.
func (h *Handlers) HandleSettings() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.renderTemplate(w, r, "settings.html", map[string]any{
"Settings": settingRows(h.params.Config),
})
}
}
// settingRows lists every field of cfg under the environment variable
// it is read from, with the description the README's configuration
// table gives it (less its pointers to other README sections), in the
// table's order. METRICS_PASSWORD and SENTRY_DSN are credentials, so
// their values never reach the page: only whether they are set.
func settingRows(cfg *config.Config) []settingRow {
metricsUsername := cfg.MetricsUsername
if metricsUsername == "" {
metricsUsername = notSet
}
return []settingRow{
{"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment},
{"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)},
{
"BIND_ADDRESS",
"IP address the HTTP listener binds. Loopback by default, " +
"so the cleartext listener is not published on every " +
"interface. The Docker image ships 0.0.0.0 instead",
cfg.BindAddress,
},
{"DATA_DIR", "Directory for all SQLite databases", cfg.DataDir},
{"DEBUG", "Enable debug logging", strconv.FormatBool(cfg.Debug)},
{
"MAINTENANCE_MODE",
"Report maintenanceMode: true in the healthcheck JSON. " +
"It does not change how any request is served — no " +
"maintenance page exists",
strconv.FormatBool(cfg.MaintenanceMode),
},
{
"METRICS_USERNAME",
"Basic auth username for /metrics. Must be set together " +
"with METRICS_PASSWORD; one without the other fails " +
"startup",
metricsUsername,
},
{
"METRICS_PASSWORD",
"Basic auth password for /metrics. Must be set together " +
"with METRICS_USERNAME; one without the other fails " +
"startup",
setOrNotSet(cfg.MetricsPassword),
},
{
"SENTRY_DSN",
"Sentry error reporting DSN. Unset leaves error reporting " +
"off; a value the Sentry SDK cannot parse fails startup " +
"rather than serving with reporting silently off",
setOrNotSet(cfg.SentryDSN),
},
{
"RETENTION_SWEEP_INTERVAL",
"How often the retention reaper and archive sweeper run " +
"(Go duration, must be positive)",
cfg.RetentionSweepInterval.String(),
},
{
"SESSION_IDLE_TIMEOUT",
"Idle session timeout (Go duration)",
cfg.SessionIdleTimeout.String(),
},
{
"RECEIVER_RATE_LIMIT",
"Receiver requests/minute per IP per entrypoint " +
"(10x that per IP across the route)",
strconv.Itoa(cfg.ReceiverRateLimit),
},
{
"TRUSTED_PROXIES",
"CIDRs whose forwarded headers are trusted. A set value " +
"replaces the default. If any client can reach webhooker, " +
"or the proxy in front of it, from an RFC 1918 source " +
"address, set it to the proxy's address alone",
cidrList(cfg.TrustedProxies),
},
{
"ALLOWED_EGRESS_CIDRS",
"CIDRs that delivery targets may reach despite the " +
"SSRF blocklist",
cidrList(cfg.AllowedEgressCIDRs),
},
}
}
// setOrNotSet is how the Settings page shows a credential: whether it
// has a value, never the value itself.
func setOrNotSet(value string) string {
if value == "" {
return notSet
}
return "set"
}
// cidrList renders a CIDR list setting for the Settings page.
func cidrList(prefixes []netip.Prefix) string {
if len(prefixes) == 0 {
return "none"
}
return strings.Join(config.PrefixStrings(prefixes), ", ")
}
+151
Visa fil
@@ -0,0 +1,151 @@
package handlers_test
import (
"context"
"html"
"net/http"
"net/http/httptest"
"net/netip"
"regexp"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
// settingsShown renders the Settings page over cfg as a logged-in user
// and returns the value it shows for each variable name, plus the
// whole page.
func settingsShown(
t *testing.T, cfg *config.Config,
) (map[string]string, string) {
t.Helper()
var h *handlers.Handlers
var sess *session.Session
app := newTestAppWithConfig(t, cfg, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/settings", nil,
)
for _, c := range authenticatedCookies(t, sess, "id", "admin") {
req.AddCookie(c)
}
w := httptest.NewRecorder()
h.HandleSettings().ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
body := w.Body.String()
row := regexp.MustCompile(
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
)
shown := map[string]string{}
for _, match := range row.FindAllStringSubmatch(body, -1) {
shown[match[1]] = html.UnescapeString(match[2])
}
return shown, body
}
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
t.Parallel()
// DEBUG and MAINTENANCE_MODE get opposite values, and each of
// METRICS_USERNAME, METRICS_PASSWORD and SENTRY_DSN is the only one
// of the three set in one of the content tests, so each row is
// checked against its own field.
cfg := &config.Config{
DataDir: t.TempDir(),
Debug: true,
MaintenanceMode: false,
Environment: config.EnvironmentDev,
MetricsUsername: "scraper",
MetricsPassword: "",
Port: 9123,
SentryDSN: "",
BindAddress: "192.0.2.10",
RetentionSweepInterval: 17 * time.Minute,
SessionIdleTimeout: 3 * time.Hour,
ReceiverRateLimit: 77,
TrustedProxies: []netip.Prefix{
netip.MustParsePrefix("10.1.0.0/16"),
},
AllowedEgressCIDRs: []netip.Prefix{
netip.MustParsePrefix("192.168.5.0/24"),
netip.MustParsePrefix("fd00::/8"),
},
}
shown, body := settingsShown(t, cfg)
assert.Equal(t, map[string]string{
"WEBHOOKER_ENVIRONMENT": "dev",
"PORT": "9123",
"BIND_ADDRESS": "192.0.2.10",
"DATA_DIR": cfg.DataDir,
"DEBUG": "true",
"MAINTENANCE_MODE": "false",
"METRICS_USERNAME": "scraper",
"METRICS_PASSWORD": "not set",
"SENTRY_DSN": "not set",
"RETENTION_SWEEP_INTERVAL": "17m0s",
"SESSION_IDLE_TIMEOUT": "3h0m0s",
"RECEIVER_RATE_LIMIT": "77",
"TRUSTED_PROXIES": "10.1.0.0/16",
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
}, shown)
assert.Contains(
t, body, `href="/settings"`,
"the navigation bar links to the page",
)
}
func TestSettingsPageShowsUnsetValues(t *testing.T) {
t.Parallel()
const metricsPassword = "metrics-password-1f9a"
shown, body := settingsShown(t, &config.Config{
DataDir: t.TempDir(),
MetricsPassword: metricsPassword,
})
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
assert.Equal(t, "set", shown["METRICS_PASSWORD"])
assert.Equal(t, "not set", shown["SENTRY_DSN"])
assert.NotContains(t, body, metricsPassword)
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
}
func TestSettingsPageShowsSentryDSNOnlyAsSet(t *testing.T) {
t.Parallel()
const (
sentryKey = "dsnkey7c2e"
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
)
shown, body := settingsShown(t, &config.Config{
DataDir: t.TempDir(),
SentryDSN: sentryDSN,
})
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
assert.Equal(t, "set", shown["SENTRY_DSN"])
assert.NotContains(t, body, sentryKey)
}
+80 -59
Visa fil
@@ -15,7 +15,6 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
@@ -80,10 +79,6 @@ func seedTarget(
// from a delete statement.
var errInjectedDelete = errors.New("injected delete failure")
// errInjectedSave is the failure failSaveOnTable reports from a
// save of an existing row.
var errInjectedSave = errors.New("injected save failure")
// seedEntrypoint inserts an entrypoint for a webhook.
func seedEntrypoint(
t *testing.T,
@@ -151,42 +146,19 @@ func failDeleteOnTable(
)
}
// failSaveOnTable is failDeleteOnTable for saves: every update of
// an existing row in the named table fails.
func failSaveOnTable(
t *testing.T,
db *database.Database,
table string,
) {
t.Helper()
require.NoError(t, db.DB().Callback().Update().
Before("gorm:update").
Register(
"test:fail_save_"+table,
func(tx *gorm.DB) {
if tx.Statement.Table == table {
_ = tx.AddError(errInjectedSave)
}
},
),
)
}
// archivePathFor returns the archive database path the
// delivery engine would use for a database target: beside the
// webhook's event database in the data directory.
// delivery engine would use for a webhook: beside the webhook's
// event database in the data directory.
func archivePathFor(
t *testing.T,
mgr *database.WebhookDBManager,
wh *database.Webhook,
tgt *database.Target,
webhookID string,
) string {
t.Helper()
return filepath.Join(
filepath.Dir(mgr.DBPath(wh.ID)),
delivery.ArchiveFileName(wh.Name, tgt.Name, tgt.ID),
filepath.Dir(mgr.DBPath(webhookID)),
"archive-"+webhookID+".db",
)
}
@@ -223,8 +195,8 @@ func postRequest(
// TestHandleSourceDelete_EvictsArchiveWriter proves that
// deleting a webhook reaches the delivery engine and releases
// the webhook's archive writers, exercised through the real
// deletion handler rather than by calling the engine directly.
// the webhook's archive writer, exercised through the real
// deletion handler rather than by calling the evictor directly.
func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
t.Parallel()
@@ -232,7 +204,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
h *handlers.Handlers
sess *session.Session
db *database.Database
ev *recordingArchives
ev *recordingEvictor
)
app := newTestApp(t, &h, &sess, &db, &ev)
@@ -282,10 +254,9 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
tgt := seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
// Place an archive file where the delivery engine would.
archivePath := archivePathFor(t, mgr, wh, tgt)
archivePath := archivePathFor(t, mgr, wh.ID)
require.NoError(
t,
writeArchivePlaceholder(archivePath),
@@ -466,17 +437,68 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
)
}
// TestHandleTargetDelete_EvictsThatTarget proves that deleting a
// database target releases that target's archive writer and no
// other: the webhook's other database target keeps its own.
func TestHandleTargetDelete_EvictsThatTarget(t *testing.T) {
// TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone
// proves that removing the last database target releases the
// archive writer.
func TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone(
t *testing.T,
) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
ev *recordingArchives
ev *recordingEvictor
)
app := newTestApp(t, &h, &sess, &db, &ev)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
tgt := seedTarget(
t, db, wh.ID, database.TargetTypeDatabase,
)
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
req := postRequest(
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/delete",
cookies,
map[string]string{
paramSourceID: wh.ID,
paramTargetID: tgt.ID,
},
)
w := httptest.NewRecorder()
h.HandleTargetDelete().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, []string{wh.ID}, ev.Evicted(),
"removing the last database target should evict",
)
}
// TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains
// proves that deleting one of several database targets leaves
// the still-needed archive writer alone: the surviving target
// keeps archiving to the same file, so the writer must stay.
func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
t *testing.T,
) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
ev *recordingEvictor
)
app := newTestApp(t, &h, &sess, &db, &ev)
@@ -507,17 +529,17 @@ func TestHandleTargetDelete_EvictsThatTarget(t *testing.T) {
h.HandleTargetDelete().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, []string{doomed.ID}, ev.EvictedTargets(),
"deleting a database target should evict its writer",
assert.Empty(
t, ev.Evicted(),
"a second database target still needs the writer",
)
assert.Empty(t, ev.Evicted(), "the webhook is not deleted")
}
// TestHandleTargetDelete_IgnoresAnotherWebhooksTarget proves that
// a target id from the URL that is not a target of the webhook
// deletes nothing and so evicts nothing.
func TestHandleTargetDelete_IgnoresAnotherWebhooksTarget(
// TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted proves
// that deleting a target of an unrelated type leaves a
// still-needed archive writer alone: the webhook's database
// target is untouched, so its writer must stay.
func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
t *testing.T,
) {
t.Parallel()
@@ -526,7 +548,7 @@ func TestHandleTargetDelete_IgnoresAnotherWebhooksTarget(
h *handlers.Handlers
sess *session.Session
db *database.Database
ev *recordingArchives
ev *recordingEvictor
)
app := newTestApp(t, &h, &sess, &db, &ev)
@@ -535,20 +557,19 @@ func TestHandleTargetDelete_IgnoresAnotherWebhooksTarget(
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
elsewhere := seedTarget(
t, db, seedWebhook(t, db).ID, database.TargetTypeDatabase,
)
seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
other := seedTarget(t, db, wh.ID, database.TargetTypeLog)
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
req := postRequest(
"/hook/"+wh.ID+"/targets/"+elsewhere.ID+"/delete",
"/hook/"+wh.ID+"/targets/"+other.ID+"/delete",
cookies,
map[string]string{
paramSourceID: wh.ID,
paramTargetID: elsewhere.ID,
paramTargetID: other.ID,
},
)
w := httptest.NewRecorder()
@@ -557,7 +578,7 @@ func TestHandleTargetDelete_IgnoresAnotherWebhooksTarget(
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Empty(
t, ev.EvictedTargets(),
"another webhook's target must not be evicted",
t, ev.Evicted(),
"a surviving database target must keep its writer",
)
}
+49 -95
Visa fil
@@ -550,7 +550,6 @@ func (h *Handlers) applyWebhookEdit(
return
}
oldName := webhook.Name
webhook.Name = name
webhook.Description = r.PostFormValue("description")
@@ -573,41 +572,8 @@ func (h *Handlers) applyWebhookEdit(
webhook.RetentionDays = retentionDays
// A new name renames the archive files before it is saved (see
// delivery.Engine.Rename). If either step fails, they go back to
// the name that is still stored.
err := h.renameWebhookArchives(webhook.ID, oldName, webhook.Name)
if err == nil {
err = h.db.DB().Save(webhook).Error
}
err := h.db.DB().Save(webhook).Error
if err != nil {
restoreErr := h.renameWebhookArchives(
webhook.ID, webhook.Name, oldName,
)
if restoreErr != nil {
h.log.Error(
"failed to rename archives back",
"webhook_id", webhook.ID,
"error", restoreErr,
)
}
if errors.Is(err, delivery.ErrArchiveNameTaken) {
data := map[string]any{
tmplKeyWebhook: webhook,
tmplKeyError: "Not saved: " + err.Error() +
". Move that archive out of the data directory, " +
"its .db together with any -wal and -shm beside " +
"it, then save again.",
}
w.WriteHeader(http.StatusConflict)
h.renderTemplate(w, r, "source_edit.html", data)
return
}
h.serverError(w, r, "failed to update webhook", err)
return
@@ -745,11 +711,11 @@ func (h *Handlers) commitWebhookDeletion(
return tx.Commit().Error
}
// evictArchiveWriter asks the delivery engine to drop the cached
// archive writers of a webhook's database targets, closing their
// archive file handles.
// evictArchiveWriter asks the delivery engine to drop its
// cached archive writer for a webhook, closing the archive file
// handle.
//
// The archive database files are NOT deleted. Unlike the event
// The archive database file is NOT deleted. Unlike the event
// database — which is per-webhook working storage and is
// hard-deleted with the webhook — an archive is explicitly
// long-term storage that an operator may want to keep or move
@@ -757,62 +723,50 @@ func (h *Handlers) commitWebhookDeletion(
// deleting a webhook would be a surprising and unrecoverable
// data loss, so the file is left for the operator to handle.
func (h *Handlers) evictArchiveWriter(webhookID string) {
if h.archives == nil {
if h.evictor == nil {
return
}
h.archives.EvictWebhook(webhookID)
h.evictor.EvictWebhook(webhookID)
}
// evictTargetArchiveWriter is evictArchiveWriter for one deleted
// target, and leaves its archive file on disk for the same reason.
// A target that is not a database target has no writer, and
// evicting it does nothing.
func (h *Handlers) evictTargetArchiveWriter(targetID string) {
if h.archives == nil {
return
}
h.archives.EvictTarget(targetID)
}
// renameWebhookArchives renames the archive file of every database
// target of a webhook from the webhook name oldName to newName,
// keeping each target's own name. It does nothing when the name is
// unchanged. It tries every target even after one fails, so that
// moving the archives back after a failed edit leaves none under the
// new name, and returns every failure joined.
func (h *Handlers) renameWebhookArchives(
webhookID, oldName, newName string,
) error {
if h.archives == nil || oldName == newName {
return nil
}
var targets []database.Target
// evictArchiveWriterIfUnused releases a webhook's archive
// writer once the webhook has no database target left to feed
// it.
//
// It is called after any child resource of a webhook is
// deleted, and is correct without knowing which kind was: it
// evicts only when no database target remains, so deleting one
// of several database targets — or deleting an unrelated
// target type — leaves a still-needed writer alone. When no
// database target ever existed there is no writer and eviction
// is a no-op. Soft-deleted targets are excluded by GORM's
// default scope, so the row just deleted is not counted.
func (h *Handlers) evictArchiveWriterIfUnused(webhookID string) {
var remaining int64
err := h.db.DB().
Model(&database.Target{}).
Where(
"webhook_id = ? AND type = ?",
webhookID, database.TargetTypeDatabase,
).
Find(&targets).Error
Count(&remaining).Error
if err != nil {
return err
}
var errs []error
for i := range targets {
err = h.archives.Rename(
targets[i].ID, newName, targets[i].Name,
h.log.Error(
"failed to count remaining database targets",
"webhook_id", webhookID,
"error", err,
)
if err != nil {
errs = append(errs, err)
}
return
}
return errors.Join(errs...)
if remaining > 0 {
return
}
h.evictArchiveWriter(webhookID)
}
// ownedWebhook resolves the request's sourceID parameter to a
@@ -1606,10 +1560,11 @@ func (h *Handlers) validateTargetURL(
msg := "Invalid target URL: " + err.Error()
// Only a private or reserved address's refusal says how
// to allow it. Metadata refusals never do: link-local and
// the other unconditional metadata addresses cannot be
// opened, and the default blocklist's public addresses,
// which listing does open, hand out credentials.
// to allow it. Other refusals never do: link-local, the
// unspecified addresses and the unconditional metadata
// addresses cannot be opened, and the default
// blocklist's public addresses, which listing does open,
// hand out credentials.
if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) {
msg += ". Private and reserved addresses are refused " +
"by default; the server's ALLOWED_EGRESS_CIDRS " +
@@ -1684,28 +1639,29 @@ func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
)
}
// HandleTargetDelete handles deleting a target. A deleted
// database target's archive writer is evicted and its handle
// closed; the archive file is left on disk.
// HandleTargetDelete handles deleting a target. Deleting the
// last database target of a webhook leaves its archive writer
// with nothing to write, so the writer is evicted and its
// handle closed; the archive file is left on disk.
func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
return h.deleteChildResource(
"targetID", &database.Target{},
"failed to delete target",
h.evictTargetArchiveWriter,
h.evictArchiveWriterIfUnused,
targetDeleted,
)
}
// deleteChildResource returns a handler that deletes a child
// resource (entrypoint or target) belonging to a webhook. The
// optional afterDelete hook runs with the child's id once the
// delete has removed it, before the redirect, which carries done as
// optional afterDelete hook runs with the webhook's id once the
// delete has succeeded, before the redirect, which carries done as
// its notice.
func (h *Handlers) deleteChildResource(
idParam string,
model any,
errMsg string,
afterDelete func(childID string),
afterDelete func(webhookID string),
done noticeCode,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -1742,10 +1698,8 @@ func (h *Handlers) deleteChildResource(
return
}
// Only for a row this webhook really had: the id came from
// the URL and may name another webhook's child.
if afterDelete != nil && result.RowsAffected > 0 {
afterDelete(childID)
if afterDelete != nil {
afterDelete(webhook.ID)
}
http.Redirect(
+1 -183
Visa fil
@@ -187,7 +187,6 @@ func storedRetentionDays(
type sourceTestEnv struct {
handlers *handlers.Handlers
db *database.Database
archives *recordingArchives
cookies []*http.Cookie
}
@@ -200,9 +199,7 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
var db *database.Database
var archives *recordingArchives
app := newTestApp(t, &h, &sess, &db, &archives)
app := newTestApp(t, &h, &sess, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
@@ -210,7 +207,6 @@ func setupSourceTest(t *testing.T) *sourceTestEnv {
return &sourceTestEnv{
handlers: h,
db: db,
archives: archives,
cookies: authenticatedCookies(
t, sess, sourceTestUserID, "sourceuser",
),
@@ -502,184 +498,6 @@ func TestHandleSourceEditSubmit_EmptyRetentionLeavesValueUnchanged(
assert.Equal(t, 7, storedRetentionDays(t, env.db, wh.ID))
}
// renamedWebhookName is the name the rename tests give a webhook.
const renamedWebhookName = "Renamed"
// TestHandleSourceEditSubmit_RenamesArchives proves that a save
// that keeps the webhook's name renames nothing, and that renaming a
// webhook renames the archive of each of its database targets and
// asks nothing of its other targets.
func TestHandleSourceEditSubmit_RenamesArchives(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
first := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
second := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
seedTarget(t, env.db, wh.ID, database.TargetTypeLog)
w := submitEdit(t, env, wh, "")
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Empty(t, env.archives.Renames())
wh.Name = renamedWebhookName
w = submitEdit(t, env, wh, "")
require.Equal(t, http.StatusSeeOther, w.Code)
assert.ElementsMatch(
t,
[]archiveRename{
{first.ID, renamedWebhookName, first.Name},
{second.ID, renamedWebhookName, second.Name},
},
env.archives.Renames(),
)
}
// TestHandleSourceEditSubmit_FailedRenameKeepsTheName proves that a
// webhook whose archive cannot be renamed keeps its stored name, so
// the name on disk and the name in the UI do not part, and that the
// handler puts back what it may already have moved.
func TestHandleSourceEditSubmit_FailedRenameKeepsTheName(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
env.archives.FailRenames(tgt.ID, errInjectedRename)
oldName := wh.Name
wh.Name = renamedWebhookName
w := submitEdit(t, env, wh, "")
require.Equal(t, http.StatusInternalServerError, w.Code)
var stored database.Webhook
require.NoError(
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
)
assert.Equal(t, oldName, stored.Name)
assert.Equal(
t,
[]archiveRename{
{tgt.ID, renamedWebhookName, tgt.Name},
{tgt.ID, oldName, tgt.Name},
},
env.archives.Renames(),
)
}
// TestHandleSourceEditSubmit_FailedSaveRenamesBack proves that when
// the archive is renamed but the new name cannot be saved, the
// archive is renamed back to the stored name and the stored name
// stays.
func TestHandleSourceEditSubmit_FailedSaveRenamesBack(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
failSaveOnTable(t, env.db, "webhooks")
oldName := wh.Name
wh.Name = renamedWebhookName
w := submitEdit(t, env, wh, "")
require.Equal(t, http.StatusInternalServerError, w.Code)
var stored database.Webhook
require.NoError(
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
)
assert.Equal(t, oldName, stored.Name)
assert.Equal(
t,
[]archiveRename{
{tgt.ID, renamedWebhookName, tgt.Name},
{tgt.ID, oldName, tgt.Name},
},
env.archives.Renames(),
)
}
// TestHandleSourceEditSubmit_FailedRenameRenamesTheOthersBack proves
// that when a webhook has two database targets and only the second
// one's archive cannot be renamed, the first is renamed back and the
// stored name stays. Every target is tried in each direction, so this
// holds whichever order the two come in.
func TestHandleSourceEditSubmit_FailedRenameRenamesTheOthersBack(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
first := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
second := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
env.archives.FailRenames(second.ID, errNameTaken)
oldName := wh.Name
wh.Name = renamedWebhookName
w := submitEdit(t, env, wh, "")
require.Equal(t, http.StatusConflict, w.Code)
var stored database.Webhook
require.NoError(
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
)
assert.Equal(t, oldName, stored.Name)
assert.ElementsMatch(
t,
[]archiveRename{
{first.ID, renamedWebhookName, first.Name},
{second.ID, renamedWebhookName, second.Name},
{first.ID, oldName, first.Name},
{second.ID, oldName, second.Name},
},
env.archives.Renames(),
)
}
// TestHandleSourceEditSubmit_ArchiveNameTaken proves that when a file
// already has an archive's new name, the edit is refused with an
// error naming that file, and the webhook keeps its stored name.
func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
env.archives.FailRenames(tgt.ID, errNameTaken)
oldName := wh.Name
wh.Name = renamedWebhookName
w := submitEdit(t, env, wh, "")
require.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "archive-taken.db")
var stored database.Webhook
require.NoError(
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
)
assert.Equal(t, oldName, stored.Name)
}
// TestSourceEditForm_ForeverWebhookRoundTrips walks the exact path that
// the removed max="365" cap used to break: render the edit form for a
// retain-forever webhook, confirm the pre-filled sentinel is not capped
-48
Visa fil
@@ -1,7 +1,6 @@
package handlers
import (
"errors"
"net/http"
"github.com/go-chi/chi"
@@ -151,43 +150,11 @@ func (h *Handlers) applyTargetEdit(
target.MaxRetries = retries
}
oldName := target.Name
target.Name = name
target.Config = configJSON
// A new name renames the archive file before it is saved (see
// delivery.Engine.Rename). If either step fails, it goes back to
// the name that is still stored.
err = h.renameTargetArchive(target, webhook.Name, oldName, name)
if err == nil {
err = h.db.DB().Save(target).Error
}
if err != nil {
restoreErr := h.renameTargetArchive(
target, webhook.Name, name, oldName,
)
if restoreErr != nil {
h.log.Error(
"failed to rename archive back",
"target_id", target.ID,
"error", restoreErr,
)
}
if errors.Is(err, delivery.ErrArchiveNameTaken) {
http.Error(
w,
"Not saved: "+err.Error()+
". Move that archive out of the data directory, "+
"its .db together with any -wal and -shm beside "+
"it, then save again.",
http.StatusConflict,
)
return
}
h.serverError(w, r, "failed to update target", err)
return
@@ -199,21 +166,6 @@ func (h *Handlers) applyTargetEdit(
)
}
// renameTargetArchive renames a database target's archive file from
// the target name oldName to newName. It does nothing when the name
// is unchanged; other target types have no archive.
func (h *Handlers) renameTargetArchive(
target *database.Target,
webhookName, oldName, newName string,
) error {
if h.archives == nil || oldName == newName ||
target.Type != database.TargetTypeDatabase {
return nil
}
return h.archives.Rename(target.ID, webhookName, newName)
}
// renderTargetEdit renders the target edit page with an optional
// error message.
func (h *Handlers) renderTargetEdit(
-97
Visa fil
@@ -635,100 +635,3 @@ func assertWebhookOfAnotherUser404s(
assert.Equal(t, http.StatusNotFound, w.Code)
}
// renamedTargetName is the name the rename tests give a target.
const renamedTargetName = "Long Term"
// TestHandleTargetEditSubmit_RenamesArchive proves that renaming a
// database target renames its archive, that a save that keeps the
// name renames nothing, that a target of another type has no archive
// to rename, and that a target whose archive cannot be renamed keeps
// its stored name. When a file already has the archive's new name,
// the edit is refused with an error naming that file.
func TestHandleTargetEditSubmit_RenamesArchive(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
rename := url.Values{"name": {renamedTargetName}}
w := submitTargetEdit(env, wh.ID, archive.ID, rename)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Equal(
t,
[]archiveRename{{archive.ID, wh.Name, renamedTargetName}},
env.archives.Renames(),
)
w = submitTargetEdit(env, wh.ID, archive.ID, rename)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Len(
t, env.archives.Renames(), 1,
"a save that keeps the name renames nothing",
)
httpWebhook, httpTarget := seedHTTPTarget(t, env, "", "")
w = submitTargetEdit(
env, httpWebhook.ID, httpTarget.ID,
editForm(editOriginalURL, "", ""),
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.Len(
t, env.archives.Renames(), 1,
"an HTTP target has no archive to rename",
)
again := url.Values{"name": {"Again"}}
env.archives.FailRenames(archive.ID, errInjectedRename)
w = submitTargetEdit(env, wh.ID, archive.ID, again)
require.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(
t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
"a target whose archive was not renamed keeps its name",
)
env.archives.FailRenames(archive.ID, errNameTaken)
w = submitTargetEdit(env, wh.ID, archive.ID, again)
require.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "archive-taken.db")
assert.Equal(
t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
)
}
// TestHandleTargetEditSubmit_FailedSaveRenamesBack proves that when a
// database target's archive is renamed but the new name cannot be
// saved, the archive is renamed back to the stored name and the
// stored name stays.
func TestHandleTargetEditSubmit_FailedSaveRenamesBack(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
failSaveOnTable(t, env.db, "targets")
form := url.Values{}
form.Set("name", renamedTargetName)
w := submitTargetEdit(env, wh.ID, archive.ID, form)
require.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(
t, archive.Name, storedTarget(t, env, archive.ID).Name,
)
assert.Equal(
t,
[]archiveRename{
{archive.ID, wh.Name, renamedTargetName},
{archive.ID, wh.Name, archive.Name},
},
env.archives.Renames(),
)
}
+7 -9
Visa fil
@@ -132,15 +132,9 @@ type noopNotifier struct{}
func (n *noopNotifier) Notify([]delivery.Task) {}
type noopArchives struct{}
type noopEvictor struct{}
func (n *noopArchives) EvictWebhook(string) {}
func (n *noopArchives) EvictTarget(string) {}
func (n *noopArchives) Rename(_, _, _ string) error {
return nil
}
func (n *noopEvictor) EvictWebhook(string) {}
// newServerApp starts the real login path against dir: the handlers,
// the middleware that bounds password verification, the session store
@@ -158,6 +152,10 @@ func newServerApp(
app := fxtest.New(
t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned.
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -169,7 +167,7 @@ func newServerApp(
healthcheck.New,
session.New,
func() delivery.Notifier { return &noopNotifier{} },
func() delivery.Archives { return &noopArchives{} },
func() delivery.WebhookEvictor { return &noopEvictor{} },
metrics.NewRegistry,
metrics.New,
middleware.New,
+24 -4
Visa fil
@@ -14,10 +14,11 @@ import (
// bytes) for form POST endpoints. 1 MB is generous for any form
// submission while preventing abuse from oversized payloads.
//
// The four admin page route groups below (/pages, /user/{username},
// /hooks and /hook/{sourceID}) install MaxBodySize(maxFormBodySize)
// right after their recoverer and error reporting, ahead of both CSRF
// and RequireAuth. Both orderings are deliberate.
// The five admin page route groups below (/pages, /user/{username},
// /settings, /hooks and /hook/{sourceID}) install
// MaxBodySize(maxFormBodySize) right after their recoverer and error
// reporting, ahead of both CSRF and RequireAuth. Both orderings are
// deliberate.
//
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
// be installed before anything reads the body, or the parse runs
@@ -154,6 +155,7 @@ func (s *Server) setupRoutes() {
s.setupPageRoutes()
s.setupUserRoutes()
s.setupSettingsRoutes()
s.setupSourceRoutes()
s.setupWebhookRoutes()
}
@@ -201,6 +203,24 @@ func (s *Server) setupUserRoutes() {
})
}
// setupSettingsRoutes serves the Settings page. It is GET only:
// configuration comes from the environment and nothing here changes
// it.
func (s *Server) setupSettingsRoutes() {
s.router.Route("/settings", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSettings())
})
}
func (s *Server) setupSourceRoutes() {
s.router.Route("/hooks", func(r chi.Router) {
s.recoverPanics(
+10 -12
Visa fil
@@ -47,18 +47,12 @@ type noopNotifier struct{}
func (n *noopNotifier) Notify([]delivery.Task) {}
// noopArchives satisfies handlers.New's delivery.Archives
// dependency. No test here checks what gets evicted or renamed, so
// it records nothing.
type noopArchives struct{}
// noopEvictor satisfies handlers.New's delivery.WebhookEvictor
// dependency. No test here checks what gets evicted, so it records
// nothing.
type noopEvictor struct{}
func (e *noopArchives) EvictWebhook(string) {}
func (e *noopArchives) EvictTarget(string) {}
func (e *noopArchives) Rename(_, _, _ string) error {
return nil
}
func (e *noopEvictor) EvictWebhook(string) {}
// testEnv is the real router from routes.go plus the collaborators
// tests need to seed users and forge sessions.
@@ -110,6 +104,10 @@ func newTestEnvWithConfig(
app := fxtest.New(
t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned.
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -119,7 +117,7 @@ func newTestEnvWithConfig(
healthcheck.New,
session.New,
func() delivery.Notifier { return &noopNotifier{} },
func() delivery.Archives { return &noopArchives{} },
func() delivery.WebhookEvictor { return &noopEvictor{} },
metrics.NewRegistry,
metrics.New,
middleware.New,
+24
Visa fil
@@ -0,0 +1,24 @@
package server_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSettingsPageIsBehindLogin(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
w := env.get("/settings", nil)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, "/pages/login?next=%2Fsettings", w.Header().Get("Location"),
)
w = env.get("/settings", env.authCookies(t, "id", "admin"))
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "WEBHOOKER_ENVIRONMENT")
}
+2
Visa fil
@@ -19,6 +19,7 @@
<div class="hidden md:flex items-center gap-4">
{{if .User}}
<a href="/hooks" class="btn-text">Webhooks</a>
<a href="/settings" class="btn-text">Settings</a>
<a href="/user/{{.User.Username}}" class="btn-text">
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
@@ -43,6 +44,7 @@
<div class="flex flex-col gap-2">
{{if .User}}
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
<a href="/settings" class="btn-text w-full text-left">Settings</a>
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
{{if .CSRFToken}}
<form method="POST" action="/pages/logout">
+30
Visa fil
@@ -0,0 +1,30 @@
{{template "base" .}}
{{define "title"}}Settings - Webhooker{{end}}
{{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8">
<h1 class="text-2xl font-medium text-gray-900">Settings</h1>
<p class="text-sm text-gray-500 mt-1 mb-6">The configuration this server started with. It is set in the server's environment and cannot be changed here.</p>
<div class="card">
<div class="divide-y divide-gray-100">
{{range .Settings}}
<div class="p-4">
<!-- A value too wide to sit beside its name moves to the
next line, where a list breaks only at the spaces
between its entries. overflow-wrap: anywhere breaks
inside a value only when it alone is wider than the
line; an inline style, because the committed
tailwind.css has no class for it. -->
<div class="flex flex-wrap justify-between items-start gap-4">
<code class="text-sm font-medium text-gray-900">{{.Name}}</code>
<code class="text-sm text-gray-900" style="overflow-wrap: anywhere">{{.Value}}</code>
</div>
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
</div>
{{end}}
</div>
</div>
</div>
{{end}}