Author SHA1 Message Date
sneak b07157aa15 Load Alpine's CSP build so the UI's directives run (closes #371)
check / check (push) Waiting to run
The pages' Content-Security-Policy forbids eval, which the standard
Alpine.js build needs, so no directive ran: add forms showed open and
events never collapsed. 3p/ now holds the @alpinejs/csp 3.14.9 tarball
instead, and every directive in templates/ names a property or method
of a component registered in static/js/app.js, as that build requires.
The policy is unchanged.

A browser test in internal/server, built only with the browser tag,
loads the webhook page and the event log under the real headers.
make test-browser runs it in Docker, in a digest-pinned headless
browser image (Dockerfile.browser).

Model: opus-5-5
2026-10-02 08:37:11 +00:00
28 changed files with 713 additions and 810 deletions
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -26,7 +26,7 @@ COPY . .
# Dockerfile.lint, including --network=none (see its header for why). # Dockerfile.lint, including --network=none (see its header for why).
RUN make fmt-check RUN make fmt-check
RUN --network=none golangci-lint config verify --config .golangci.yml RUN --network=none golangci-lint config verify --config .golangci.yml
RUN --network=none golangci-lint run --config .golangci.yml ./... RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
# Build stage # Build stage
# golang:1.26.1-bookworm (Debian-based), 2026-03-17 # golang:1.26.1-bookworm (Debian-based), 2026-03-17
+29
View File
@@ -0,0 +1,29 @@
# Browser test image, built by script/test-browser (make test-browser). It
# runs the test in internal/server that loads the pages in a headless
# browser under the real Content-Security-Policy. That test is built only
# with the browser build tag, so make test leaves it out. Here the browser
# comes from a digest-pinned image, and if it is missing the test fails.
# golang:1.26.1-bookworm, 2026-03-17: the builder stage's image in Dockerfile.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# The test binary embeds the templates and static files, so the browser
# stage needs nothing else. -p 4 keeps the compile's memory down, as in
# script/test.
RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
# browser is on PATH as headless-shell, where the test's browser library
# looks for it.
FROM chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04 AS browser
COPY --from=build /browser.test /browser.test
RUN /browser.test -test.v -test.timeout 90s -test.run '^TestAlpineRunsUnderTheSecurityPolicy$'
+3 -1
View File
@@ -34,4 +34,6 @@ COPY . .
# `run` silently ignores config keys it does not recognize, so a typo would # `run` silently ignores config keys it does not recognize, so a typo would
# disable a setting without a word. `config verify` is what catches that. # disable a setting without a word. `config verify` is what catches that.
RUN --network=none golangci-lint config verify --config .golangci.yml RUN --network=none golangci-lint config verify --config .golangci.yml
RUN --network=none golangci-lint run --config .golangci.yml ./... # --build-tags browser also lints the browser test, which is built only with
# that tag (make test-browser).
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version .PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css version
# Default target # Default target
.DEFAULT_GOAL := check .DEFAULT_GOAL := check
@@ -33,6 +33,9 @@ assets:
test: test:
@script/test @script/test
test-browser:
@script/test-browser
lint: lint:
@script/lint @script/lint
+69 -64
View File
@@ -19,8 +19,8 @@ before deploying one.
### Prerequisites ### Prerequisites
- Go 1.26.1+ (the version in `go.mod`) - Go 1.26.1+ (the version in `go.mod`)
- Docker (for linting, for the test stage of the CI gate, and for - Docker (for linting, for the browser test, for the test stage of the
containerized deployment) CI gate, and for containerized deployment)
golangci-lint is not a prerequisite and must not be installed on the golangci-lint is not a prerequisite and must not be installed on the
host: `script/bootstrap` does not install it, and `make lint` runs the host: `script/bootstrap` does not install it, and `make lint` runs the
@@ -58,6 +58,7 @@ make fmt # Format code (gofmt + goimports)
make fmt-check # Fail if gofmt would change anything (writes nothing) make fmt-check # Fail if gofmt would change anything (writes nothing)
make lint # Run golangci-lint in Docker (Dockerfile.lint) make lint # Run golangci-lint in Docker (Dockerfile.lint)
make test # Run tests with race detection make test # Run tests with race detection
make test-browser # Run the browser test in Docker (Dockerfile.browser)
make check # test + lint + fmt-check (CI gate) make check # test + lint + fmt-check (CI gate)
make build # Build binary to bin/webhooker (version-stamped) make build # Build binary to bin/webhooker (version-stamped)
make version # Print the version this checkout would stamp make version # Print the version this checkout would stamp
@@ -158,20 +159,19 @@ WireServer, which serves an Azure VM its credentials. Because it is a
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it. public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it.
That is all the default blocklist covers: the IPv4 private and reserved That is all the default blocklist covers: the IPv4 private and reserved
ranges; of IPv6, only loopback (`::1`), the unspecified address (`::`), ranges; of IPv6, only loopback (`::1`), unique local addresses
unique local addresses (`fc00::/7`), link-local addresses (`fe80::/10`), (`fc00::/7`) and link-local addresses (`fe80::/10`); and certain public
multicast (`ff00::/8`) and documentation space (`2001:db8::/32`); and addresses. A public address belongs on the default blocklist only if it
certain public addresses. A public address belongs on the default hands credentials, user data or bootstrap material to whatever can reach
blocklist only if it hands credentials, user data or bootstrap material it, without the caller presenting anything. A provider's other public
to whatever can reach it, without the caller presenting anything. A addresses are not refused. IBM Cloud, for example, serves its package
provider's other public addresses are not refused. IBM Cloud, for mirrors, time servers and object storage on `161.26.0.0/16`, and the
example, serves its package mirrors, time servers and object storage on private endpoints of its own cloud services on `166.8.0.0/14`. Neither
`161.26.0.0/16`, and the private endpoints of its own cloud services on range hands out credentials that way: the token service among those
`166.8.0.0/14`. Neither range hands out credentials that way: the token endpoints issues a token only in exchange for something the caller
service among those endpoints issues a token only in exchange for presents, such as an API key. Reaching these services can be a
something the caller presents, such as an API key. Reaching these legitimate delivery, and every cloud has some, so a partial list would
services can be a legitimate delivery, and every cloud has some, so a promise coverage it does not give.
partial list would promise coverage it does not give.
That default is also inconvenient for the thing webhooker is mostly That default is also inconvenient for the thing webhooker is mostly
for: taking a public webhook and forwarding it to something on your own for: taking a public webhook and forwarding it to something on your own
@@ -211,16 +211,16 @@ Two things this setting cannot do:
the list is always an allowlist; an empty list (the default) means the list is always an allowlist; an empty list (the default) means
every private and reserved range stays refused. Note that every private and reserved range stays refused. Note that
`0.0.0.0/0` gets you most of the way there anyway, per above. `0.0.0.0/0` gets you most of the way there anyway, per above.
- **It cannot open link-local, the unspecified addresses, or a cloud - **It cannot open link-local, or a cloud metadata endpoint at a
metadata endpoint at a non-public address that discloses credentials non-public address that discloses credentials or user data.** An
or user data.** A metadata address is on the list below when it is not address is on the list below when it is not a public address and both
a public address and both of these hold: the provider fixes it, so it of these hold: the provider fixes it, so it cannot collide with
cannot collide with anything you run; and reaching it hands out anything you run; and reaching it hands out credentials, user data or
credentials, user data or bootstrap material. Those stay blocked no bootstrap material. Those stay blocked no matter what you list,
matter what you list, including when you list them outright or list a including when you list them outright or list a supernet such as
supernet such as `0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`. `0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`. Treat this as best
Treat this as best effort rather than a guarantee — it is a effort rather than a guarantee — it is a hand-maintained list and the
hand-maintained list and the caveat below the table applies: caveat below the table applies:
| Blocked unconditionally | What it is | | Blocked unconditionally | What it is |
| ----------------------- | ---------- | | ----------------------- | ---------- |
@@ -234,25 +234,14 @@ Two things this setting cannot do:
| `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 | | `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 |
| `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT | | `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT |
| `192.0.0.192/32` | Oracle Cloud Classic metadata | | `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 | | `::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 | | `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 The IPv4-mapped form `::ffff:169.254.169.254` is covered by the
`169.254.0.0/16` entry. Reaching any of these but the two unspecified `169.254.0.0/16` entry. Reaching any of these is credential or
addresses is credential or user-data theft rather than delivery to an user-data theft rather than delivery to an internal service. Every
internal service. Every entry outside the two link-local blocks is a entry outside the two link-local blocks is a single address, so
single address, so blocking it costs you nothing else on the network blocking it costs you nothing else on the network around it.
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 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 practice: `fd00::/8` is an ordinary block to allowlist for your own
@@ -1252,7 +1241,7 @@ What that means for an operator:
This repository adheres to the This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the standard: normalized scripts in `script/` are the entrypoints for the
development workflow. Ten of the Makefile's seventeen targets are thin development workflow. Eleven of the Makefile's eighteen targets are thin
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
`version` are inline commands with no script behind them, though `build`, `version` are inline commands with no script behind them, though `build`,
`run` and `dev` first run `script/assets`, and `build` and `version` both `run` and `dev` first run `script/assets`, and `build` and `version` both
@@ -1273,6 +1262,8 @@ We provide:
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see - `script/assets` — extract Alpine.js from its tarball in `3p/` (see
[Third-party browser assets](#third-party-browser-assets)) [Third-party browser assets](#third-party-browser-assets))
- `script/test` — run the test suite - `script/test` — run the test suite
- `script/test-browser` — run the browser test in Docker (see
[Third-party browser assets](#third-party-browser-assets))
- `script/lint` — run golangci-lint in Docker (see Linting below) - `script/lint` — run golangci-lint in Docker (see Linting below)
- `script/fmt` — format all code (writes) - `script/fmt` — format all code (writes)
- `script/fmt-check` — check formatting (read-only) - `script/fmt-check` — check formatting (read-only)
@@ -1293,9 +1284,30 @@ We provide:
## Third-party browser assets ## Third-party browser assets
The web UI serves one third-party script, Alpine.js. Its npm package tarball The web UI serves one third-party script, Alpine.js, in its CSP build: the npm
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry package `@alpinejs/csp`. The pages' Content-Security-Policy forbids eval, which
publishes it. It is a dependency, not this repo's build output, so the standard `alpinejs` build needs to run the expressions written in the
markup. The CSP build runs no expressions, so every Alpine directive in
`templates/` only names a property or method of a component registered in
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
`x-data="{ open: false }"` or `@click="open = !open"`.
A browser test in `internal/server` loads the webhook page and the event log
under the real policy and checks that: both add forms stay hidden until Add is
clicked; choosing Slack in the add target form leaves the HTTP fields out of
what it submits, also after leaving the page and going back to it, when the
browser restores the choice; an event expands and collapses, and so do a
delivery's attempts inside it; and at phone width the menu button opens and
closes the mobile menu. It also fails if the browser reports a console warning
or error, an uncaught exception, or anything the policy refused. It is not part
of `make test`, `make check` or the image build (its file is built only with the
`browser` build tag). Run it with `make test-browser` after changing
`templates/` or `static/js/`: that builds `Dockerfile.browser`, which runs the
test in a digest-pinned headless browser image, so the host needs no browser.
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
byte as the npm registry publishes it. It is a dependency, not this repo's
build output, so
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply. `REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
The directory is `3p/` rather than `vendor/` because Go treats a root The directory is `3p/` rather than `vendor/` because Go treats a root
`vendor/` directory as its module vendor directory. `vendor/` directory as its module vendor directory.
@@ -1308,10 +1320,11 @@ nothing downloads Alpine.js. The extracted file is not committed, and
`.dockerignore` keeps any host copy out of the build context. `.dockerignore` keeps any host copy out of the build context.
To move to a new version: download To move to a new version: download
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it `https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it
against the `dist.integrity` hash listed at against the `dist.integrity` hash listed at
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/` `https://registry.npmjs.org/@alpinejs/csp/<version>`, replace the tarball in
with it, update its file name in `script/assets`, and run `make check`. `3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
`script/assets`, and run `make check`.
## Rationale ## Rationale
@@ -1780,7 +1793,7 @@ retries) is individually logged for full observability.
#### EventTotals and TargetTotals #### EventTotals and TargetTotals
Running counts in each event database, read by the statistics pane at the Running counts in each event database, read by the statistics pane at the
top of the webhook page and by the webhook list. `EventTotals` is one row: top of the webhook page. `EventTotals` is one row:
| Field | Type | Description | | Field | Type | Description |
| ---------------- | --------- | ----------- | | ---------------- | --------- | ----------- |
@@ -1812,14 +1825,6 @@ target. Its failure percentage for a window is the deliveries that became
`failed` in it out of all that became `delivered` or `failed` in it, and `failed` in it out of all that became `delivered` or `failed` in it, and
a dash when none did. a dash when none did.
The webhook list at `/hooks` shows three of the pane's figures for each
webhook: its events within retention and its last event, both from
`EventTotals`, and its deliveries that failed in the last 24 hours,
counted with the pane's query. It opens each webhook's event database once
(the handle stays open) and runs those two reads there, so its cost grows
with the number of webhooks and, for each, with the deliveries that
finished in the last 24 hours, never with the events stored.
#### Event-tier indexes #### Event-tier indexes
These indexes on the per-webhook event databases are declared in the model These indexes on the per-webhook event databases are declared in the model
@@ -1827,7 +1832,7 @@ tags, so `AutoMigrate` creates them on a fresh database:
| Table | Columns | Serves | | Table | Columns | Serves |
| ------------------ | --------------------------- | ------ | | ------------------ | --------------------------- | ------ |
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics and the webhook list, which count each target's deliveries by status and when they finished | | `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics, which count each target's deliveries by status and when they finished |
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events | | `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events | | `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events | | `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
@@ -2851,7 +2856,7 @@ imports. The entry point is `cmd/webhooker/main.go`.
``` ```
webhooker/ webhooker/
├── 3p/ ├── 3p/
│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets │ └── alpinejs-csp-3.14.9.tgz # Alpine.js CSP build npm package, extracted by make assets
├── cmd/webhooker/ ├── cmd/webhooker/
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx │ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
├── internal/ ├── internal/
@@ -2945,13 +2950,14 @@ webhooker/
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css) │ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
│ ├── css/tailwind.css # Generated stylesheet the pages load │ ├── css/tailwind.css # Generated stylesheet the pages load
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded │ ├── css/style.css # Older hand-written stylesheet, no longer loaded
│ ├── js/app.js # Progressive-enhancement copy-to-clipboard │ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed │ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
├── templates/ # Go HTML templates (base, login, sources, etc.) ├── templates/ # Go HTML templates (base, login, sources, etc.)
├── script/ # Scripts to Rule Them All entrypoints ├── script/ # Scripts to Rule Them All entrypoints
├── Dockerfile # Three stages: lint, test+build, Alpine runtime ├── Dockerfile # Three stages: lint, test+build, Alpine runtime
├── Dockerfile.lint # Lint-only image built by script/lint ├── Dockerfile.lint # Lint-only image built by script/lint
├── Makefile # 10 of 17 targets shim script/; 7 are inline ├── Dockerfile.browser # Browser test image built by script/test-browser
├── Makefile # 11 of 18 targets shim script/; 7 are inline
├── go.mod / go.sum ├── go.mod / go.sum
└── .golangci.yml # Linter configuration └── .golangci.yml # Linter configuration
``` ```
@@ -3113,8 +3119,7 @@ check, see [The login endpoint](#the-login-endpoint).
route through a single decision function, so they cannot disagree route through a single decision function, so they cannot disagree
about a destination. An operator can permit specific blocks with about a destination. An operator can permit specific blocks with
[`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the [`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the
guard cannot be switched off, and link-local, the unspecified guard cannot be switched off, and link-local plus a
addresses `0.0.0.0` and `::`, and a
[pinned set](#allowing-egress-to-your-own-network) of known cloud [pinned set](#allowing-egress-to-your-own-network) of known cloud
metadata endpoints — several of which are ULAs outside link-local — metadata endpoints — several of which are ULAs outside link-local —
stay blocked whatever is listed, though listing `0.0.0.0/0` or stay blocked whatever is listed, though listing `0.0.0.0/0` or
+8 -1
View File
@@ -4,6 +4,8 @@ go 1.26.1
require ( require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f
github.com/chromedp/chromedp v0.16.0
github.com/dustin/go-humanize v1.0.1 github.com/dustin/go-humanize v1.0.1
github.com/getsentry/sentry-go v0.25.0 github.com/getsentry/sentry-go v0.25.0
github.com/go-chi/chi v1.5.5 github.com/go-chi/chi v1.5.5
@@ -29,7 +31,12 @@ require (
require ( require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
@@ -50,7 +57,7 @@ require (
go.uber.org/zap v1.23.0 // indirect go.uber.org/zap v1.23.0 // indirect
golang.org/x/mod v0.17.0 // indirect golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.14.0 // indirect golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.25.0 // indirect golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.31.0 // indirect google.golang.org/protobuf v1.31.0 // indirect
+20 -2
View File
@@ -6,6 +6,12 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk=
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE=
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -23,6 +29,14 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
@@ -55,12 +69,16 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -111,8 +129,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
+6 -7
View File
@@ -196,13 +196,12 @@ type Config struct {
// otherwise refuse. The guard itself is always on: there is no // otherwise refuse. The guard itself is always on: there is no
// setting that disables SSRF protection, and delivery's // setting that disables SSRF protection, and delivery's
// alwaysBlockedNetworks stays blocked no matter what is listed // alwaysBlockedNetworks stays blocked no matter what is listed
// here. That set is link-local, the unspecified addresses // here. That set is link-local plus the cloud metadata
// 0.0.0.0 and ::, and the cloud metadata endpoints outside // endpoints outside it that disclose credentials or user data
// link-local that disclose credentials or user data at a // at a provider-fixed, non-public address; it is not
// provider-fixed, non-public address; it is not exhaustive of // exhaustive of every cloud's metadata address. See
// every cloud's metadata address. See // alwaysBlockedNetworks for the authoritative list and the
// alwaysBlockedNetworks for the authoritative list and why // criterion it is built from.
// each entry is on it.
AllowedEgressCIDRs []netip.Prefix AllowedEgressCIDRs []netip.Prefix
params *ConfigParams params *ConfigParams
-12
View File
@@ -124,11 +124,6 @@ func testEnvironmentConfigSuccess(
app := fxtest.New( app := fxtest.New(
t, 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( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -277,7 +272,6 @@ func testRetentionSweepIntervalSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -370,7 +364,6 @@ func testSessionIdleTimeoutSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -411,7 +404,6 @@ func TestDefaultDataDir(t *testing.T) {
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -542,7 +534,6 @@ func testReceiverRateLimitSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -659,7 +650,6 @@ func testTrustedProxiesSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -773,7 +763,6 @@ func testAllowedEgressCIDRsSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -1017,7 +1006,6 @@ func assertMetricsAuthAccepted(t *testing.T, expectAuth bool) {
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide(globals.New, logger.New, config.New), fx.Provide(globals.New, logger.New, config.New),
fx.Populate(&cfg), fx.Populate(&cfg),
) )
+13 -57
View File
@@ -37,8 +37,8 @@ var (
"blocked cloud metadata address", "blocked cloud metadata address",
) )
errBlockedMetadata = errors.New( errBlockedMetadata = errors.New(
"blocked link-local, cloud instance metadata or " + "blocked link-local or cloud instance metadata " +
"unspecified address: ALLOWED_EGRESS_CIDRS cannot open it", "address: ALLOWED_EGRESS_CIDRS cannot open it",
) )
errInvalidScheme = errors.New( errInvalidScheme = errors.New(
"only http and https are allowed", "only http and https are allowed",
@@ -72,17 +72,14 @@ var blockedNetworks []*net.IPNet
var blockedPublicNetworks []*net.IPNet var blockedPublicNetworks []*net.IPNet
// alwaysBlockedNetworks are the ranges no configuration can // alwaysBlockedNetworks are the ranges no configuration can
// open, so a supplied CIDR that covers one still leaves it // open: the link-local blocks and the cloud instance metadata
// blocked. An entry is here for one of two reasons: it is a // endpoints that live outside them. Reaching one is credential
// metadata endpoint (the link-local blocks and the cloud // or user-data theft rather than delivery to an internal
// instance metadata endpoints that live outside them), or it is // service, so a supplied CIDR that covers such an address still
// an unspecified address. Reaching a metadata endpoint is // leaves it blocked.
// credential or user-data theft rather than delivery to an
// internal service.
// //
// Inclusion criterion for metadata endpoints — one belongs here // Inclusion criterion — an address belongs here only if BOTH
// only if BOTH hold, and every metadata entry below satisfies // hold, and every entry below satisfies both:
// both:
// //
// 1. It is a fixed address assigned by the provider, or a // 1. It is a fixed address assigned by the provider, or a
// range reserved by IANA — never one the operator chose. // range reserved by IANA — never one the operator chose.
@@ -93,8 +90,8 @@ var blockedPublicNetworks []*net.IPNet
// not cheaply rotated. // not cheaply rotated.
// //
// Both halves are load-bearing, so use them to refuse a // Both halves are load-bearing, so use them to refuse a
// metadata candidate and say why. An endpoint disclosing only // candidate and say why. An endpoint disclosing only the
// the operator's own inventory (instance id, region, disks, NICs) // operator's own inventory (instance id, region, disks, NICs)
// fails (2): letting a delivery target reach the operator's own // fails (2): letting a delivery target reach the operator's own
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to // infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
// provide. But (2) is not "IAM credentials only" either — // provide. But (2) is not "IAM credentials only" either —
@@ -115,15 +112,6 @@ var blockedPublicNetworks []*net.IPNet
// This is a criterion, not an enumeration of every metadata // This is a criterion, not an enumeration of every metadata
// address in existence. // 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 // Every entry is either already in blockedNetworks — this list is
// what makes it unconditional — or an alternate encoding of // what makes it unconditional — or an alternate encoding of
// 169.254.169.254 that Contains does not match against // 169.254.169.254 that Contains does not match against
@@ -143,46 +131,23 @@ var alwaysBlockedNetworks []*net.IPNet
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup //nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
func init() { func init() {
blockedNetworks = mustParseCIDRs([]string{ blockedNetworks = mustParseCIDRs([]string{
// IPv4 loopback.
"127.0.0.0/8", "127.0.0.0/8",
// RFC 1918 private network.
"10.0.0.0/8", "10.0.0.0/8",
// RFC 1918 private network.
"172.16.0.0/12", "172.16.0.0/12",
// RFC 1918 private network.
"192.168.0.0/16", "192.168.0.0/16",
// IPv4 link-local.
"169.254.0.0/16", "169.254.0.0/16",
// "This network", holding the IPv4 unspecified address 0.0.0.0.
"0.0.0.0/8", "0.0.0.0/8",
// Carrier-grade NAT shared address space.
"100.64.0.0/10", "100.64.0.0/10",
// IETF protocol assignments.
"192.0.0.0/24", "192.0.0.0/24",
// IPv4 documentation (TEST-NET-1).
"192.0.2.0/24", "192.0.2.0/24",
// Benchmarking.
"198.18.0.0/15", "198.18.0.0/15",
// IPv4 documentation (TEST-NET-2).
"198.51.100.0/24", "198.51.100.0/24",
// IPv4 documentation (TEST-NET-3).
"203.0.113.0/24", "203.0.113.0/24",
// IPv4 multicast.
"224.0.0.0/4", "224.0.0.0/4",
// Reserved, including the broadcast address.
"240.0.0.0/4", "240.0.0.0/4",
// IPv6 loopback.
"::1/128", "::1/128",
// IPv6 unspecified address.
"::/128",
// IPv6 unique local addresses.
"fc00::/7", "fc00::/7",
// IPv6 link-local.
"fe80::/10", "fe80::/10",
// IPv6 multicast.
"ff00::/8",
// IPv6 documentation.
"2001:db8::/32",
}) })
blockedPublicNetworks = mustParseCIDRs([]string{ blockedPublicNetworks = mustParseCIDRs([]string{
@@ -242,14 +207,6 @@ func init() {
// allowlist from opening it. // allowlist from opening it.
"192.0.0.192/32", "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. // 169.254.169.254 as an IPv4-compatible IPv6 address.
"::a9fe:a9fe/128", "::a9fe:a9fe/128",
// 169.254.169.254 behind the NAT64 well-known prefix. // 169.254.169.254 behind the NAT64 well-known prefix.
@@ -386,9 +343,8 @@ func (g *Guard) allows(ip net.IP) bool {
// The order is the policy: // The order is the policy:
// //
// 1. alwaysBlockedNetworks is refused before the allowlist is // 1. alwaysBlockedNetworks is refused before the allowlist is
// consulted, so no configured CIDR reaches link-local, a // consulted, so no configured CIDR reaches link-local or a
// cloud metadata endpoint at a non-public address, or an // cloud metadata endpoint at a non-public address.
// unspecified address.
// 2. The allowlist is consulted next, so a listed private // 2. The allowlist is consulted next, so a listed private
// network, or a listed public address on the default // network, or a listed public address on the default
// blocklist, becomes reachable. // blocklist, becomes reachable.
+11 -39
View File
@@ -168,13 +168,12 @@ func TestGuardAllowlist_UnlistedPrivateStillRefused(t *testing.T) {
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing // TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
// case: cloud instance metadata endpoints are credential theft // case: cloud instance metadata endpoints are credential theft
// rather than delivery to an internal service, and the // rather than delivery to an internal service, so no allowlist
// unspecified addresses 0.0.0.0 and :: reach this host's loopback // reaches one. Every guard below names a CIDR that covers its
// on Linux, so no allowlist reaches any of them. Every guard // target — including 0.0.0.0/0, ::/0, and the ordinary ULA and
// below names a CIDR that covers its target — including // CGNAT blocks an operator would really list — and the address
// 0.0.0.0/0, ::/0, and the ordinary ULA and CGNAT blocks an // must stay refused anyway, on both the validation and the
// operator would really list — and the address must stay // delivery path.
// refused anyway, on both the validation and the delivery path.
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) { func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
t.Parallel() t.Parallel()
@@ -220,17 +219,15 @@ type metadataAlwaysRefusedCase struct {
} }
// metadataAlwaysRefusedCases enumerates every unconditionally // metadataAlwaysRefusedCases enumerates every unconditionally
// blocked address (link-local, the cloud metadata endpoints and // blocked address together with an allowlist entry that would
// the unspecified addresses) together with an allowlist entry // otherwise reach it. Split by family of address only to stay
// that would otherwise reach it. Split by family of address only // under the function-length limit.
// to stay under the function-length limit.
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase { func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
cases := linkLocalRefusedCases() cases := linkLocalRefusedCases()
cases = append(cases, ulaMetadataRefusedCases()...) cases = append(cases, ulaMetadataRefusedCases()...)
cases = append(cases, ipv4MetadataRefusedCases()...) cases = append(cases, ipv4MetadataRefusedCases()...)
cases = append(cases, encodedMetadataRefusedCases()...)
return append(cases, unspecifiedRefusedCases()...) return append(cases, encodedMetadataRefusedCases()...)
} }
// linkLocalRefusedCases covers the link-local blocks, including // linkLocalRefusedCases covers the link-local blocks, including
@@ -370,23 +367,6 @@ 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 // TestGuardAllowlist_PublicUnaffected asserts the allowlist does
// not narrow anything: public addresses were reachable before it // not narrow anything: public addresses were reachable before it
// existed and stay reachable, whether or not a list is set. // existed and stay reachable, whether or not a list is set.
@@ -544,10 +524,6 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
// Oracle Cloud Classic metadata, inside the blocked // Oracle Cloud Classic metadata, inside the blocked
// 192.0.0.0/24. // 192.0.0.0/24.
"192.0.0.192/32", "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. // 169.254.169.254 as an IPv4-compatible IPv6 address.
"::a9fe:a9fe/128", "::a9fe:a9fe/128",
// 169.254.169.254 behind the NAT64 well-known prefix. // 169.254.169.254 behind the NAT64 well-known prefix.
@@ -580,8 +556,7 @@ func TestDefaultBlocklist_PinnedSet(t *testing.T) {
{cidr: "172.16.0.0/12", reopenable: true}, {cidr: "172.16.0.0/12", reopenable: true},
{cidr: "192.168.0.0/16", reopenable: true}, {cidr: "192.168.0.0/16", reopenable: true},
{cidr: linkLocalIPv4, reopenable: false}, {cidr: linkLocalIPv4, reopenable: false},
// Its first address, 0.0.0.0, is in the unconditional set. {cidr: "0.0.0.0/8", reopenable: true},
{cidr: "0.0.0.0/8", reopenable: false},
{cidr: "100.64.0.0/10", reopenable: true}, {cidr: "100.64.0.0/10", reopenable: true},
{cidr: "192.0.0.0/24", reopenable: true}, {cidr: "192.0.0.0/24", reopenable: true},
{cidr: "192.0.2.0/24", reopenable: true}, {cidr: "192.0.2.0/24", reopenable: true},
@@ -591,11 +566,8 @@ func TestDefaultBlocklist_PinnedSet(t *testing.T) {
{cidr: "224.0.0.0/4", reopenable: true}, {cidr: "224.0.0.0/4", reopenable: true},
{cidr: "240.0.0.0/4", reopenable: true}, {cidr: "240.0.0.0/4", reopenable: true},
{cidr: "::1/128", reopenable: true}, {cidr: "::1/128", reopenable: true},
{cidr: "::/128", reopenable: false},
{cidr: "fc00::/7", reopenable: true}, {cidr: "fc00::/7", reopenable: true},
{cidr: "fe80::/10", reopenable: false}, {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}, {cidr: "168.63.129.16/32", public: true, reopenable: true},
} }
-36
View File
@@ -101,42 +101,6 @@ 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) { func TestValidateTargetURL_Allowed(t *testing.T) {
t.Parallel() t.Parallel()
-4
View File
@@ -137,10 +137,6 @@ func bootAtDebug(t *testing.T, dataDir string) string {
app := fxtest.New( app := fxtest.New(
t, 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( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
-4
View File
@@ -86,10 +86,6 @@ func newTestApp(
return fxtest.New( return fxtest.New(
t, 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( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
-405
View File
@@ -1,405 +0,0 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session"
)
// failedHighlight is how the list marks a number of failed deliveries
// that is not zero.
const failedHighlight = `class="font-medium text-red-600"`
// listWebhook adds a webhook with the given name, owned by the test
// user.
func listWebhook(
t *testing.T, db *database.Database, name string,
) *database.Webhook {
t.Helper()
wh := &database.Webhook{UserID: deleteTestUserID, Name: name}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
return wh
}
// addEntrypoints adds the given number of entrypoints, all active or
// all inactive, to a webhook and returns their paths.
func addEntrypoints(
t *testing.T, db *database.Database, webhookID string,
count int, active bool,
) []string {
t.Helper()
paths := make([]string, count)
for i := range paths {
paths[i] = statsEntrypoint(t, db, webhookID, active)
}
return paths
}
// addTargets adds the given number of targets, all active or all
// inactive, to a webhook and returns them.
func addTargets(
t *testing.T, db *database.Database, webhookID string,
count int, active bool,
) []*database.Target {
t.Helper()
targets := make([]*database.Target, count)
for i := range targets {
targets[i] = seedTarget(t, db, webhookID, database.TargetTypeLog)
require.NoError(t, db.DB().Model(targets[i]).
Update("active", active).Error)
}
return targets
}
// renderWebhookList runs the real webhook list handler as the test user
// and returns the rendered page.
func renderWebhookList(
t *testing.T, h *handlers.Handlers, sess *session.Session,
) string {
t.Helper()
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
w := httptest.NewRecorder()
h.HandleSourceList().ServeHTTP(
w, getRequest(t, "/hooks", cookies, nil),
)
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}
// listCard returns one webhook's entry in a rendered webhook list, its
// markup as rendered and its text with the markup taken out and each
// run of space made one space.
func listCard(t *testing.T, page, webhookID string) (string, string) {
t.Helper()
_, card, found := strings.Cut(page, `href="/hook/`+webhookID+`"`)
require.True(t, found, "the list has no entry for %s", webhookID)
card, _, _ = strings.Cut(card, "</a>")
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(card, " ")
return card, strings.Join(strings.Fields(text), " ")
}
// receiveEvents posts the given number of events to an entrypoint
// through the real receiver, and returns the webhook's event database
// and its events, oldest first.
func receiveEvents(
t *testing.T,
h *handlers.Handlers,
dbMgr *database.WebhookDBManager,
webhookID, path string,
count int,
) (*gorm.DB, []database.Event) {
t.Helper()
router := receiverRouter(h)
for range count {
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
}
webhookDB, err := dbMgr.GetDB(webhookID)
require.NoError(t, err)
events := listEvents(t, webhookDB)
require.Len(t, events, count)
return webhookDB, events
}
// seedFailingWebhook adds a webhook with six entrypoints, two of them
// inactive, and seven targets, five of them inactive. Four events reach
// its two active targets, each event arriving at a different time.
// Three deliveries failed in the last 24 hours, two to the first target
// and one to the second, one failed 30 hours ago, and one was
// delivered. It returns the webhook and its newest event.
func seedFailingWebhook(
t *testing.T,
h *handlers.Handlers,
db *database.Database,
dbMgr *database.WebhookDBManager,
) (*database.Webhook, database.Event) {
t.Helper()
wh := listWebhook(t, db, "failing")
paths := addEntrypoints(t, db, wh.ID, 4, true)
addEntrypoints(t, db, wh.ID, 2, false)
active := addTargets(t, db, wh.ID, 2, true)
first, second := active[0], active[1]
addTargets(t, db, wh.ID, 5, false)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 4)
now := time.Now()
statsAge(t, webhookDB, events[0].ID, now.Add(-31*time.Hour))
statsAge(t, webhookDB, events[1].ID, now.Add(-2*time.Hour))
statsAge(t, webhookDB, events[2].ID, now.Add(-10*time.Minute))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[0].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-30*time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[1].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[2].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-time.Minute))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[2].ID, second.ID),
database.DeliveryStatusFailed, now.Add(-time.Minute))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[3].ID, second.ID),
database.DeliveryStatusDelivered, now.Add(-time.Minute))
return wh, events[3]
}
// seedHealthyWebhook adds a webhook with four entrypoints and two
// targets, all active, and three events, each delivered to both
// targets. It returns the webhook and its newest event.
func seedHealthyWebhook(
t *testing.T,
h *handlers.Handlers,
db *database.Database,
dbMgr *database.WebhookDBManager,
) (*database.Webhook, database.Event) {
t.Helper()
wh := listWebhook(t, db, "healthy")
paths := addEntrypoints(t, db, wh.ID, 4, true)
targets := addTargets(t, db, wh.ID, 2, true)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 3)
for _, ev := range events {
for _, target := range targets {
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, ev.ID, target.ID),
database.DeliveryStatusDelivered, time.Now())
}
}
return wh, events[2]
}
// lastEventText is how the list shows the arrival of an event.
func lastEventText(ev database.Event) string {
return ev.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
}
// TestSourceList_ShowsActivityOfEachWebhook checks the figures the list
// shows for a webhook with recent failures, a healthy one, a new one
// that has received no event, and one without an event database.
func TestSourceList_ShowsActivityOfEachWebhook(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
failing, failingNewest := seedFailingWebhook(t, h, db, dbMgr)
healthy, healthyNewest := seedHealthyWebhook(t, h, db, dbMgr)
// Creating a webhook creates its event database.
fresh := listWebhook(t, db, "fresh")
require.NoError(t, dbMgr.CreateDB(fresh.ID))
addEntrypoints(t, db, fresh.ID, 2, true)
addTargets(t, db, fresh.ID, 3, true)
quiet := listWebhook(t, db, "quiet")
addEntrypoints(t, db, quiet.ID, 2, true)
addTargets(t, db, quiet.ID, 3, true)
page := renderWebhookList(t, h, sess)
card, text := listCard(t, page, failing.ID)
assert.Contains(t, text, "6 entrypoints, 2 inactive")
assert.Contains(t, text, "7 targets, 5 inactive")
assert.Contains(t, text, "4 events within retention")
assert.Contains(t, text, "Last event "+lastEventText(failingNewest))
assert.Contains(t, card,
failedHighlight+">3 failed deliveries in the last 24 hours<")
card, text = listCard(t, page, healthy.ID)
assert.Contains(t, text, "4 entrypoints")
assert.Contains(t, text, "2 targets")
assert.Contains(t, text, "3 events within retention")
assert.Contains(t, text, "Last event "+lastEventText(healthyNewest))
assert.Contains(t, text, "0 failed deliveries in the last 24 hours")
assert.NotContains(t, text, "inactive")
assert.NotContains(t, card, failedHighlight)
card, text = listCard(t, page, fresh.ID)
assert.Contains(t, text, "2 entrypoints")
assert.Contains(t, text, "3 targets")
assert.Contains(t, text, "0 events within retention")
assert.Contains(t, text, "No events yet")
assert.Contains(t, text, "0 failed deliveries in the last 24 hours")
assert.NotContains(t, card, failedHighlight)
card, text = listCard(t, page, quiet.ID)
assert.Contains(t, text, "2 entrypoints")
assert.Contains(t, text, "3 targets")
assert.Contains(t, text, "0 events within retention")
assert.Contains(t, text, "No events yet")
assert.Contains(t, text, "0 failed deliveries in the last 24 hours")
assert.NotContains(t, card, failedHighlight)
assert.False(t, dbMgr.DBExists(quiet.ID),
"showing the list must not create an event database")
}
// TestSourceList_CountsOnlyEventsWithinRetention checks that once
// retention has removed one of a webhook's three events, the list
// counts the two still stored.
func TestSourceList_CountsOnlyEventsWithinRetention(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "pruned", RetentionDays: 14,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
paths := addEntrypoints(t, db, wh.ID, 3, true)
addTargets(t, db, wh.ID, 4, true)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 3)
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-15*24*time.Hour))
statsPrune(t, db, dbMgr, log, webhookDB)
require.Len(t, listEvents(t, webhookDB), 2)
_, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
assert.Contains(t, text, "3 entrypoints")
assert.Contains(t, text, "4 targets")
assert.Contains(t, text, "2 events within retention")
}
// TestSourceList_LastEventSurvivesPruningEveryEvent checks that once
// retention has removed every event of a webhook, the list still shows
// when the last one arrived rather than "No events yet".
func TestSourceList_LastEventSurvivesPruningEveryEvent(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "emptied", RetentionDays: 1,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
paths := addEntrypoints(t, db, wh.ID, 2, true)
addTargets(t, db, wh.ID, 3, true)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 1)
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
statsPrune(t, db, dbMgr, log, webhookDB)
require.Empty(t, listEvents(t, webhookDB))
_, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
assert.Contains(t, text, "0 events within retention")
assert.Contains(t, text, "Last event "+lastEventText(events[0]))
assert.NotContains(t, text, "No events yet")
}
// TestSourceList_UnreadableEventDatabase checks that a webhook whose
// event database cannot be read says so in its entry instead of
// showing zeros, and that the rest of the list is still shown.
func TestSourceList_UnreadableEventDatabase(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
broken := listWebhook(t, db, "broken")
addEntrypoints(t, db, broken.ID, 2, true)
addTargets(t, db, broken.ID, 3, true)
brokenDB, err := dbMgr.GetDB(broken.ID)
require.NoError(t, err)
require.NoError(t,
brokenDB.Migrator().DropTable(&database.EventTotals{}))
quiet := listWebhook(t, db, "quiet")
addEntrypoints(t, db, quiet.ID, 2, true)
addTargets(t, db, quiet.ID, 3, true)
page := renderWebhookList(t, h, sess)
_, text := listCard(t, page, broken.ID)
assert.Contains(t, text, "2 entrypoints")
assert.Contains(t, text, "3 targets")
assert.Contains(t, text, "The event figures could not be read.")
assert.NotContains(t, text, "events")
assert.NotContains(t, text, "failed")
_, text = listCard(t, page, quiet.ID)
assert.Contains(t, text, "No events yet")
}
+26 -124
View File
@@ -3,12 +3,10 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/google/uuid" "github.com/google/uuid"
@@ -22,20 +20,9 @@ import (
type WebhookListItem struct { type WebhookListItem struct {
database.Webhook database.Webhook
EntrypointCount int EntrypointCount int64
InactiveEntrypointCount int TargetCount int64
TargetCount int EventCount int64
InactiveTargetCount int
// EventCount is how many events the webhook holds, LastEventAt
// when the newest arrived (nil before the first), and
// FailedLast24Hours how many of its deliveries failed in the last
// 24 hours. When the webhook's event database could not be read,
// EventsUnreadable is set and these three are not known.
EventCount int64
LastEventAt *time.Time
FailedLast24Hours int64
EventsUnreadable bool
} }
// errMissingURL signals that a required URL was not provided. // errMissingURL signals that a required URL was not provided.
@@ -167,12 +154,7 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
return return
} }
items, err := h.buildWebhookListItems(webhooks) items := h.buildWebhookListItems(webhooks)
if err != nil {
h.serverError(w, r, "failed to list webhooks", err)
return
}
data := map[string]any{ data := map[string]any{
"Webhooks": items, "Webhooks": items,
@@ -182,115 +164,36 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
} }
} }
// buildWebhookListItems builds the list's entry for each webhook. It // buildWebhookListItems builds list items with counts.
// fails when the main database cannot be read. A webhook whose event
// database cannot be read is marked on its own entry, and the error is
// logged.
func (h *Handlers) buildWebhookListItems( func (h *Handlers) buildWebhookListItems(
webhooks []database.Webhook, webhooks []database.Webhook,
) ([]WebhookListItem, error) { ) []WebhookListItem {
items := make([]WebhookListItem, len(webhooks)) items := make([]WebhookListItem, len(webhooks))
since := time.Now().Add(-longWindow)
for i := range webhooks { for i := range webhooks {
item := &items[i] items[i].Webhook = webhooks[i]
item.Webhook = webhooks[i]
var err error h.db.DB().Model(&database.Entrypoint{}).Where(
"webhook_id = ?", webhooks[i].ID,
).Count(&items[i].EntrypointCount)
item.EntrypointCount, item.InactiveEntrypointCount, err = h.db.DB().Model(&database.Target{}).Where(
h.countWithInactive(&database.Entrypoint{}, item.ID) "webhook_id = ?", webhooks[i].ID,
if err != nil { ).Count(&items[i].TargetCount)
return nil, err
}
item.TargetCount, item.InactiveTargetCount, err = if h.dbMgr.DBExists(webhooks[i].ID) {
h.countWithInactive(&database.Target{}, item.ID) webhookDB, err := h.dbMgr.GetDB(
if err != nil { webhooks[i].ID,
return nil, err
}
// Opening an event database that does not exist would create
// it, and it would hold nothing to count.
if !h.dbMgr.DBExists(item.ID) {
continue
}
err = h.readListEventFigures(item, since)
if err != nil {
h.log.Error(
"failed to read webhook list figures",
"webhook_id", item.ID,
"error", err,
) )
if err == nil {
item.EventsUnreadable = true webhookDB.Model(
&database.Event{},
).Count(&items[i].EventCount)
}
} }
} }
return items, nil return items
}
// countWithInactive returns how many entrypoints or targets, as model
// says, a webhook has, and how many of them are inactive.
func (h *Handlers) countWithInactive(
model any, webhookID string,
) (int, int, error) {
var active []bool
err := h.db.DB().Model(model).
Where("webhook_id = ?", webhookID).
Pluck("active", &active).Error
if err != nil {
return 0, 0, fmt.Errorf(
"reading active flags of webhook %s: %w", webhookID, err,
)
}
inactive := 0
for _, a := range active {
if !a {
inactive++
}
}
return len(active), inactive, nil
}
// readListEventFigures fills in the figures the list shows from the
// webhook's event database, with the statistics pane's own queries:
// the event count and last arrival from the event totals row, and the
// deliveries that failed since the given time from the deliveries'
// status index.
func (h *Handlers) readListEventFigures(
item *WebhookListItem, since time.Time,
) error {
webhookDB, err := h.dbMgr.GetDB(item.ID)
if err != nil {
return err
}
var totals database.EventTotals
err = webhookDB.Take(&totals).Error
if err != nil {
return fmt.Errorf("reading event totals: %w", err)
}
item.EventCount = totals.Events - totals.EventsRemoved
item.LastEventAt = totals.LastEventAt
byTarget, err := finishedByTarget(webhookDB, since)
if err != nil {
return err
}
for _, f := range byTarget {
item.FailedLast24Hours += f.Failed
}
return nil
} }
// HandleSourceCreate shows the form to create a new webhook. // HandleSourceCreate shows the form to create a new webhook.
@@ -1657,11 +1560,10 @@ func (h *Handlers) validateTargetURL(
msg := "Invalid target URL: " + err.Error() msg := "Invalid target URL: " + err.Error()
// Only a private or reserved address's refusal says how // Only a private or reserved address's refusal says how
// to allow it. Other refusals never do: link-local, the // to allow it. Metadata refusals never do: link-local and
// unspecified addresses and the unconditional metadata // the other unconditional metadata addresses cannot be
// addresses cannot be opened, and the default // opened, and the default blocklist's public addresses,
// blocklist's public addresses, which listing does open, // which listing does open, hand out credentials.
// hand out credentials.
if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) { if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) {
msg += ". Private and reserved addresses are refused " + msg += ". Private and reserved addresses are refused " +
"by default; the server's ALLOWED_EGRESS_CIDRS " + "by default; the server's ALLOWED_EGRESS_CIDRS " +
-4
View File
@@ -152,10 +152,6 @@ func newServerApp(
app := fxtest.New( app := fxtest.New(
t, 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( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
+391
View File
@@ -0,0 +1,391 @@
//go:build browser
// This test needs a headless browser, so it is built only with the
// browser build tag: `make test` leaves it out, and `make test-browser`
// runs it in the browser image that Dockerfile.browser pins.
package server_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
)
const (
// browserTimeout bounds everything one test does in the browser.
browserTimeout = 60 * time.Second
// settleTimeout bounds the wait for an element to show or hide.
settleTimeout = 5 * time.Second
// The window size of a phone, narrow enough that the pages show
// the mobile menu button instead of the navigation links.
phoneWidth = 390
phoneHeight = 844
)
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
// event log in a headless browser, served by the real router and so
// under the real Content-Security-Policy, and checks that the pages'
// Alpine.js directives work.
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
t.Parallel()
ctx, problems := startBrowser(t)
env := newTestEnv(t)
srv := httptest.NewServer(env.router)
t.Cleanup(srv.Close)
userID, _ := env.seedUser(t, "browser", "browser-password")
webhook := env.seedWebhook(t, userID)
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
target := env.seedTarget(t, webhook.ID)
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
webhookDB, err := env.dbMgr.GetDB(webhook.ID)
require.NoError(t, err)
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
&database.DeliveryResult{
DeliveryID: dlv.ID,
AttemptNum: 1,
StatusCode: http.StatusBadGateway,
},
).Error)
require.NoError(t, chromedp.Run(
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
))
page := srv.URL + "/hook/" + webhook.ID
checkAddForms(ctx, t, page)
checkTargetType(ctx, t, page+"/events")
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
checkMobileMenu(ctx, t, page)
assert.Empty(t, problems(), "the browser reported problems")
}
// startBrowser starts a headless browser for one test. It returns the
// context that drives it, and a function listing what the browser
// reported going wrong on its pages: console warnings and errors,
// which is how Alpine.js reports an expression it cannot run; uncaught
// exceptions; and every entry in the browser's own security log, which
// is where it reports each script, style, image or request the
// Content-Security-Policy refused.
//
// The browser library finds the browser on PATH. Without one the first
// chromedp.Run fails, and with it the test.
func startBrowser(t *testing.T) (context.Context, func() []string) {
t.Helper()
allocCtx, cancelAlloc := chromedp.NewExecAllocator(
t.Context(),
append(
chromedp.DefaultExecAllocatorOptions[:],
// Dockerfile.browser runs the test as root, where the
// browser's sandbox cannot start.
chromedp.NoSandbox,
)...,
)
t.Cleanup(cancelAlloc)
ctx, cancel := chromedp.NewContext(allocCtx)
t.Cleanup(cancel)
ctx, cancelTimeout := context.WithTimeout(ctx, browserTimeout)
t.Cleanup(cancelTimeout)
var (
mu sync.Mutex
problems []string
)
chromedp.ListenTarget(ctx, func(ev any) {
var problem string
switch ev := ev.(type) {
case *runtime.EventConsoleAPICalled:
if ev.Type != runtime.APITypeWarning &&
ev.Type != runtime.APITypeError {
return
}
args := make([]string, 0, len(ev.Args))
for _, arg := range ev.Args {
args = append(args, string(arg.Value))
}
problem = strings.Join(args, " ")
case *runtime.EventExceptionThrown:
problem = ev.ExceptionDetails.Error()
case *log.EventEntryAdded:
if ev.Entry.Source != log.SourceSecurity {
return
}
problem = ev.Entry.Text
default:
return
}
mu.Lock()
defer mu.Unlock()
problems = append(problems, problem)
})
return ctx, func() []string {
mu.Lock()
defer mu.Unlock()
return slices.Clone(problems)
}
}
// setCookies gives the browser the cookies for the server at base.
func setCookies(base string, cookies []*http.Cookie) chromedp.ActionFunc {
return chromedp.ActionFunc(func(ctx context.Context) error {
for _, c := range cookies {
err := network.SetCookie(c.Name, c.Value).
WithURL(base).
Do(ctx)
if err != nil {
return fmt.Errorf("set cookie %s: %w", c.Name, err)
}
}
return nil
})
}
// loadPage opens url and waits for Alpine.js to start, which it does
// by removing every x-cloak attribute. Until then x-cloak hides the
// elements Alpine would hide, so a check made earlier proves nothing.
func loadPage(url string) chromedp.Tasks {
return chromedp.Tasks{
chromedp.Navigate(url),
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
}
}
// shown waits up to settleTimeout for the elements matching a CSS
// selector or an XPath expression to be rendered, and reports whether
// they were. The wait is needed because Alpine.js shows an element on
// the next animation frame, not at once.
func shown(ctx context.Context, selector string) bool {
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
defer cancel()
return chromedp.Run(
ctx, chromedp.WaitVisible(selector, chromedp.BySearch),
) == nil
}
// hidden is shown's opposite: it waits for the elements to be hidden.
func hidden(ctx context.Context, selector string) bool {
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
defer cancel()
return chromedp.Run(
ctx, chromedp.WaitNotVisible(selector, chromedp.BySearch),
) == nil
}
// click clicks the element matching an XPath expression.
func click(ctx context.Context, t *testing.T, xpath string) {
t.Helper()
require.NoError(t, chromedp.Run(
ctx, chromedp.Click(xpath, chromedp.BySearch),
))
}
// checkAddForms loads a webhook page and checks that each section's add
// form stays hidden until the Add button beside its heading is clicked.
func checkAddForms(ctx context.Context, t *testing.T, url string) {
t.Helper()
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
sections := []struct{ heading, form string }{
{"Entrypoints", `form[action$="/entrypoints"]`},
{"Targets", `form[action$="/targets"]`},
}
for _, s := range sections {
assert.Truef(
t, hidden(ctx, s.form),
"%s: the add form shows before Add is clicked", s.heading,
)
click(ctx, t, `//h2[text()="`+s.heading+
`"]/following-sibling::button`)
assert.Truef(
t, shown(ctx, s.form),
"%s: the add form stays hidden when Add is clicked", s.heading,
)
}
}
// checkTargetType chooses Slack in the open add target form and checks
// what the form would then submit: one url field, the Slack one, and
// not the HTTP url, headers or timeout, which are hidden and disabled.
//
// It then opens the page at elsewhere and goes back. The browser loads
// the webhook page again and restores the form as it was left, Slack
// chosen, without a change event; the form must again show and submit
// Slack's fields, not the HTTP ones.
func checkTargetType(ctx context.Context, t *testing.T, elsewhere string) {
t.Helper()
const (
chooseSlack = `(() => {
const type = document.querySelector('select[name="type"]');
type.value = "slack";
type.dispatchEvent(new Event("change"));
})()`
chosen = `document.querySelector('select[name="type"]').value`
howLoaded = `performance.getEntriesByType("navigation")[0].type`
submitted = `[...new FormData(
document.querySelector('form[action$="/targets"]')).keys()]`
slackURL = `input[placeholder^="https://hooks.slack.com/"]`
httpURL = `input[placeholder="https://example.com/webhook"]`
)
slackFields := strings.Fields("csrf_token name type max_retries url")
var fields []string
require.NoError(t, chromedp.Run(
ctx,
chromedp.Evaluate(chooseSlack, nil),
chromedp.Evaluate(submitted, &fields),
))
assert.Equal(
t, slackFields, fields,
"with Slack chosen, the HTTP fields must not be submitted",
)
var loaded, restored string
// Going back waits for the load event, after which the browser has
// restored the form.
require.NoError(t, chromedp.Run(
ctx,
loadPage(elsewhere),
chromedp.NavigateBack(),
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
chromedp.Evaluate(howLoaded, &loaded),
chromedp.Evaluate(chosen, &restored),
))
// A page the browser kept in memory and showed again as it was
// would prove nothing here.
require.Equal(
t, "back_forward", loaded,
"going back, the browser did not load the page again",
)
require.Equal(
t, "slack", restored,
"going back, the browser did not restore the chosen type",
)
click(ctx, t, `//h2[text()="Targets"]/following-sibling::button`)
assert.True(t, shown(ctx, slackURL),
"going back with Slack chosen, the Slack fields are not shown")
assert.True(t, hidden(ctx, httpURL),
"going back with Slack chosen, the HTTP fields are shown")
require.NoError(t, chromedp.Run(
ctx, chromedp.Evaluate(submitted, &fields),
))
assert.Equal(
t, slackFields, fields,
"going back with Slack chosen, the HTTP fields must not be submitted",
)
}
// checkEventLog loads the event log and checks that clicking an event's
// row expands it, that in there clicking its delivery shows the
// delivery's attempts and clicking again hides them, and that clicking
// the event's row again collapses it.
func checkEventLog(
ctx context.Context, t *testing.T, url, eventID, targetName string,
) {
t.Helper()
// The event's row shows its ID, and its Resubmit form is in the part
// that expands. The delivery's row there shows the target's name.
eventRow := `//span[text()="` + eventID + `"]`
expanded := `form[action$="/resubmit"]`
deliveryRow := `//span[text()="` + targetName + `"]`
attempt := `//span[text()="Attempt 1"]`
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
click(ctx, t, eventRow)
assert.True(t, shown(ctx, expanded), "clicking the event does not expand it")
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
click(ctx, t, deliveryRow)
assert.True(t, shown(ctx, attempt),
"clicking the delivery does not show its attempts")
click(ctx, t, deliveryRow)
assert.True(t, hidden(ctx, attempt),
"clicking the delivery again does not hide its attempts")
click(ctx, t, eventRow)
assert.True(t, hidden(ctx, expanded),
"clicking the event again does not collapse it")
}
// checkMobileMenu loads a page in a phone-sized window and checks that
// the menu button opens and closes the mobile menu.
func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
t.Helper()
// The menu button is the only button directly in the navigation
// bar's top row. Profile is a link only the mobile menu has.
button := `//nav/div/button`
menu := `//nav//a[text()="Profile"]`
require.NoError(t, chromedp.Run(
ctx,
chromedp.EmulateViewport(phoneWidth, phoneHeight),
loadPage(url),
))
assert.True(t, hidden(ctx, menu), "the mobile menu starts open")
click(ctx, t, button)
assert.True(t, shown(ctx, menu), "the menu button does not open the menu")
click(ctx, t, button)
assert.True(t, hidden(ctx, menu), "the menu button does not close the menu")
}
-4
View File
@@ -104,10 +104,6 @@ func newTestEnvWithConfig(
app := fxtest.New( app := fxtest.New(
t, 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( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
+5 -4
View File
@@ -1,15 +1,16 @@
#!/bin/sh #!/bin/sh
# script/assets: extract Alpine.js from its npm package tarball, committed # script/assets: extract Alpine.js from its npm package tarball, committed
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The # in 3p/, to static/js/alpine.min.js, where go:embed reads it. The package
# extracted file is not committed. script/test, make build and make dev run # is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy
# this first. # forbids eval. The extracted file is not committed. script/test, make
# build and make dev run this first.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \ tar -xzOf 3p/alpinejs-csp-3.14.9.tgz package/dist/cdn.min.js \
>static/js/alpine.min.js >static/js/alpine.min.js
} }
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# script/test-browser: run the browser test in internal/server. It runs in
# Docker: Dockerfile.browser builds the test and runs it in a digest-pinned
# headless browser image, so the host needs no browser.
#
# --no-cache-filter=browser runs the test again even when nothing changed;
# it must name the stage in Dockerfile.browser that runs it.
# --output=type=cacheonly leaves no image behind to clean up.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build \
-f Dockerfile.browser \
--no-cache-filter=browser \
--progress=plain \
--output=type=cacheonly \
.
}
main "$@"
+70
View File
@@ -57,3 +57,73 @@
init(); init();
} }
})(); })();
// Alpine.js components.
//
// The pages' Content-Security-Policy forbids eval, so the UI loads
// Alpine's CSP build, which cannot run expressions written in the
// markup: a directive in templates/ may only name a property or method,
// and each x-data names a component registered here. This script runs
// before Alpine, whose script tag is deferred, so this listener is in
// place when Alpine starts.
document.addEventListener("alpine:init", function () {
"use strict";
// Something a click shows and hides: the mobile menu, an add form,
// an event in the event log, a delivery's attempts.
window.Alpine.data("collapsible", function () {
return {
open: false,
toggle() {
this.open = !this.open;
},
get closed() {
return !this.open;
},
// Turns a downward caret up while open.
get caretClass() {
return { "rotate-180": this.open };
},
};
});
// The add target form. Only the chosen type's fields show, and the
// others are disabled so that the form does not submit them.
//
// The type is read from the type select when Alpine starts, when the
// select changes, and on pageshow. Going back to the page, the
// browser restores the type chosen before without a change event,
// in some browsers only after Alpine has started, but always before
// pageshow.
window.Alpine.data("targetForm", function () {
return {
targetType: "",
init() {
this.readType();
},
readType() {
this.targetType = this.$root.querySelector(
'select[name="type"]'
).value;
},
get isHttp() {
return this.targetType === "http";
},
get isSlack() {
return this.targetType === "slack";
},
get isDatabase() {
return this.targetType === "database";
},
get notHttp() {
return !this.isHttp;
},
get notSlack() {
return !this.isSlack;
},
get notDatabase() {
return !this.isDatabase;
},
};
});
});
+3 -3
View File
@@ -1,5 +1,5 @@
{{define "navbar"}} {{define "navbar"}}
<nav class="app-bar" x-data="{ open: false }"> <nav class="app-bar" x-data="collapsible">
<div class="max-w-6xl mx-auto flex justify-between items-center"> <div class="max-w-6xl mx-auto flex justify-between items-center">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a> <a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a>
@@ -7,9 +7,9 @@
<!-- Mobile menu button --> <!-- Mobile menu button -->
{{if .User}} {{if .User}}
<button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100"> <button @click="toggle" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/> <path x-show="closed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> <path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg> </svg>
</button> </button>
+20 -20
View File
@@ -7,7 +7,7 @@
event log, the navbar and the footer, so an entrypoint URL fits on event log, the navbar and the footer, so an entrypoint URL fits on
one line. An inline style, because the committed tailwind.css has one line. An inline style, because the committed tailwind.css has
no class this wide. --> no class this wide. -->
<div class="mx-auto px-6 py-8" style="max-width: 108rem" x-data="{ showAddEntrypoint: false, showAddTarget: false }"> <div class="mx-auto px-6 py-8" style="max-width: 108rem">
<div class="mb-6"> <div class="mb-6">
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a> <a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a>
<div class="flex flex-wrap justify-between items-center gap-2 mt-2"> <div class="flex flex-wrap justify-between items-center gap-2 mt-2">
@@ -32,10 +32,10 @@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Entrypoints --> <!-- Entrypoints -->
<div class="card"> <div class="card" x-data="collapsible">
<div class="p-4 border-b border-gray-200 flex justify-between items-center"> <div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">Entrypoints</h2> <h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
<button @click="showAddEntrypoint = !showAddEntrypoint" class="btn-text text-sm"> <button @click="toggle" class="btn-text text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg> </svg>
@@ -44,7 +44,7 @@
</div> </div>
<!-- Add entrypoint form --> <!-- Add entrypoint form -->
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200"> <div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/hook/{{.Webhook.ID}}/entrypoints" class="flex gap-2"> <form method="POST" action="/hook/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1"> <input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
@@ -91,10 +91,10 @@
</div> </div>
<!-- Targets --> <!-- Targets -->
<div class="card"> <div class="card" x-data="collapsible">
<div class="p-4 border-b border-gray-200 flex justify-between items-center"> <div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">Targets</h2> <h2 class="text-lg font-medium text-gray-900">Targets</h2>
<button @click="showAddTarget = !showAddTarget" class="btn-text text-sm"> <button @click="toggle" class="btn-text text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg> </svg>
@@ -103,42 +103,42 @@
</div> </div>
<!-- Add target form --> <!-- Add target form -->
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200"> <div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3"> <form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="targetForm" @pageshow.window="readType" class="space-y-3">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="flex gap-2"> <div class="flex gap-2">
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1"> <input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
<select name="type" x-model="targetType" class="input text-sm w-32"> <select name="type" @change="readType" class="input text-sm w-32">
<option value="http">HTTP</option> <option value="http">HTTP</option>
<option value="slack">Slack</option> <option value="slack">Slack</option>
<option value="database">Database</option> <option value="database">Database</option>
<option value="log">Log</option> <option value="log">Log</option>
</select> </select>
</div> </div>
<div x-show="targetType === 'http'"> <div x-show="isHttp">
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm"> <input type="url" name="url" placeholder="https://example.com/webhook" :disabled="notHttp" class="input text-sm">
</div> </div>
<div x-show="targetType === 'http'"> <div x-show="isHttp">
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="targetType !== 'http'" class="input text-sm"></textarea> <textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="notHttp" class="input text-sm"></textarea>
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p> <p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
</div> </div>
<div x-show="targetType === 'http'" class="flex gap-2 items-center"> <div x-show="isHttp" class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label> <label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
<input type="number" name="timeout" min="0" max="300" :disabled="targetType !== 'http'" class="input text-sm w-24"> <input type="number" name="timeout" min="0" max="300" :disabled="notHttp" class="input text-sm w-24">
</div> </div>
<div x-show="targetType === 'http'"> <div x-show="isHttp">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Max retries:</label> <label class="text-sm text-gray-700">Max retries:</label>
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24"> <input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
</div> </div>
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p> <p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
</div> </div>
<div x-show="targetType === 'slack'"> <div x-show="isSlack">
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" class="input text-sm"> <input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="notSlack" class="input text-sm">
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p> <p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
</div> </div>
<div x-show="targetType === 'database'"> <div x-show="isDatabase">
<input type="text" name="expiry" placeholder="never" :disabled="targetType !== 'database'" class="input text-sm"> <input type="text" name="expiry" placeholder="never" :disabled="notDatabase" class="input text-sm">
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p> <p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
</div> </div>
<button type="submit" class="btn-primary text-sm">Add Target</button> <button type="submit" class="btn-primary text-sm">Add Target</button>
+7 -7
View File
@@ -15,8 +15,8 @@
<div class="card"> <div class="card">
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Events}} {{range .Events}}
<div class="p-4" x-data="{ open: false }"> <div class="p-4" x-data="collapsible">
<div class="flex items-center justify-between cursor-pointer" @click="open = !open"> <div class="flex items-center justify-between cursor-pointer" @click="toggle">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="badge-info">{{.Method}}</span> <span class="badge-info">{{.Method}}</span>
<span class="text-sm font-mono text-gray-700">{{.ID}}</span> <span class="text-sm font-mono text-gray-700">{{.ID}}</span>
@@ -35,7 +35,7 @@
</span> </span>
{{end}} {{end}}
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span> <span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg> </svg>
</div> </div>
@@ -63,8 +63,8 @@
<h3 class="text-xs font-medium uppercase tracking-wide text-gray-500">Deliveries</h3> <h3 class="text-xs font-medium uppercase tracking-wide text-gray-500">Deliveries</h3>
<div class="mt-2 divide-y divide-gray-200"> <div class="mt-2 divide-y divide-gray-200">
{{range .Deliveries}} {{range .Deliveries}}
<div class="py-2" x-data="{ attempts: false }"> <div class="py-2" x-data="collapsible">
<div class="flex items-center justify-between cursor-pointer" @click="attempts = !attempts"> <div class="flex items-center justify-between cursor-pointer" @click="toggle">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span> <span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span> <span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
@@ -78,13 +78,13 @@
</form> </form>
{{end}} {{end}}
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span> <span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="{ 'rotate-180': attempts }" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg> </svg>
</div> </div>
</div> </div>
<div x-show="attempts" x-cloak class="mt-2 space-y-2"> <div x-show="open" x-cloak class="mt-2 space-y-2">
{{if .AttemptsOmitted}} {{if .AttemptsOmitted}}
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p> <p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
{{end}} {{end}}
+4 -10
View File
@@ -27,16 +27,10 @@
</div> </div>
<span class="badge-info">Retention: {{.RetentionLabel}}</span> <span class="badge-info">Retention: {{.RetentionLabel}}</span>
</div> </div>
<div class="flex flex-wrap gap-6 mt-4 text-sm text-gray-500"> <div class="flex gap-6 mt-4 text-sm text-gray-500">
<span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}{{if .InactiveEntrypointCount}}, {{.InactiveEntrypointCount}} inactive{{end}}</span> <span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}</span>
<span>{{.TargetCount}} target{{if ne .TargetCount 1}}s{{end}}{{if .InactiveTargetCount}}, {{.InactiveTargetCount}} inactive{{end}}</span> <span>{{.TargetCount}} target{{if ne .TargetCount 1}}s{{end}}</span>
{{if .EventsUnreadable}} <span>{{.EventCount}} event{{if ne .EventCount 1}}s{{end}}</span>
<span class="text-red-600">The event figures could not be read.</span>
{{else}}
<span>{{.EventCount}} event{{if ne .EventCount 1}}s{{end}} within retention</span>
<span>{{with .LastEventAt}}Last event {{.UTC.Format "2006-01-02 15:04:05 UTC"}}{{else}}No events yet{{end}}</span>
<span class="{{if .FailedLast24Hours}}font-medium text-red-600{{end}}">{{.FailedLast24Hours}} failed deliver{{if eq .FailedLast24Hours 1}}y{{else}}ies{{end}} in the last 24 hours</span>
{{end}}
</div> </div>
</a> </a>
{{end}} {{end}}