Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b93ecf4cb1 | ||
|
|
38157d8936 |
Binary file not shown.
Binary file not shown.
@@ -1,29 +0,0 @@
|
|||||||
# 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$'
|
|
||||||
+1
-3
@@ -34,6 +34,4 @@ 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
|
||||||
# --build-tags browser also lints the browser test, which is built only with
|
RUN --network=none golangci-lint run --config .golangci.yml ./...
|
||||||
# that tag (make test-browser).
|
|
||||||
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css version
|
.PHONY: bootstrap setup assets test 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,9 +33,6 @@ assets:
|
|||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
test-browser:
|
|
||||||
@script/test-browser
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@script/lint
|
@script/lint
|
||||||
|
|
||||||
|
|||||||
@@ -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 browser test, for the test stage of the
|
- Docker (for linting, for the test stage of the CI gate, and for
|
||||||
CI gate, and for containerized deployment)
|
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,7 +58,6 @@ 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
|
||||||
@@ -1225,7 +1224,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. Eleven of the Makefile's eighteen targets are thin
|
development workflow. Ten of the Makefile's seventeen 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
|
||||||
@@ -1246,8 +1245,6 @@ 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)
|
||||||
@@ -1268,30 +1265,9 @@ We provide:
|
|||||||
|
|
||||||
## Third-party browser assets
|
## Third-party browser assets
|
||||||
|
|
||||||
The web UI serves one third-party script, Alpine.js, in its CSP build: the npm
|
The web UI serves one third-party script, Alpine.js. Its npm package tarball
|
||||||
package `@alpinejs/csp`. The pages' Content-Security-Policy forbids eval, which
|
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry
|
||||||
the standard `alpinejs` build needs to run the expressions written in the
|
publishes it. It is a dependency, not this repo's build output, so
|
||||||
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.
|
||||||
@@ -1304,11 +1280,10 @@ 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/csp/-/csp-<version>.tgz`, check it
|
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it
|
||||||
against the `dist.integrity` hash listed at
|
against the `dist.integrity` hash listed at
|
||||||
`https://registry.npmjs.org/@alpinejs/csp/<version>`, replace the tarball in
|
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
|
||||||
`3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
|
with it, update its file name in `script/assets`, and run `make check`.
|
||||||
`script/assets`, and run `make check`.
|
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
@@ -1777,7 +1752,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. `EventTotals` is one row:
|
top of the webhook page and by the webhook list. `EventTotals` is one row:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------------- | --------- | ----------- |
|
| ---------------- | --------- | ----------- |
|
||||||
@@ -1809,6 +1784,14 @@ 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
|
||||||
@@ -1816,7 +1799,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, 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 and the webhook list, 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 |
|
||||||
@@ -2840,7 +2823,7 @@ imports. The entry point is `cmd/webhooker/main.go`.
|
|||||||
```
|
```
|
||||||
webhooker/
|
webhooker/
|
||||||
├── 3p/
|
├── 3p/
|
||||||
│ └── alpinejs-csp-3.14.9.tgz # Alpine.js CSP build npm package, extracted by make assets
|
│ └── alpinejs-3.14.9.tgz # Alpine.js 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/
|
||||||
@@ -2934,14 +2917,13 @@ 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 # Copy-to-clipboard, and the Alpine.js components
|
│ ├── js/app.js # Progressive-enhancement copy-to-clipboard
|
||||||
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
|
│ └── js/alpine.min.js # Alpine.js, 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
|
||||||
├── Dockerfile.browser # Browser test image built by script/test-browser
|
├── Makefile # 10 of 17 targets shim script/; 7 are inline
|
||||||
├── 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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ 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
|
||||||
@@ -31,12 +29,7 @@ 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
|
||||||
@@ -57,7 +50,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.47.0 // indirect
|
golang.org/x/sys v0.37.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
|
||||||
|
|||||||
@@ -6,12 +6,6 @@ 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=
|
||||||
@@ -29,14 +23,6 @@ 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=
|
||||||
@@ -69,16 +55,12 @@ 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=
|
||||||
@@ -129,8 +111,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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
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=
|
||||||
|
|||||||
@@ -40,11 +40,6 @@ const (
|
|||||||
ExportPendingSweepMinAge = pendingSweepMinAge
|
ExportPendingSweepMinAge = pendingSweepMinAge
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExportIsBlockedIP exposes isBlockedIP for testing.
|
|
||||||
func ExportIsBlockedIP(ip net.IP) bool {
|
|
||||||
return isBlockedIP(ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTestGuard builds an SSRF Guard from an explicit egress
|
// NewTestGuard builds an SSRF Guard from an explicit egress
|
||||||
// allowlist, without going through config. Passing no prefixes
|
// allowlist, without going through config. Passing no prefixes
|
||||||
// yields the default guard, which blocks every private/reserved
|
// yields the default guard, which blocks every private/reserved
|
||||||
@@ -70,6 +65,11 @@ func ExportBlockedNetworks() []*net.IPNet {
|
|||||||
return blockedNetworks
|
return blockedNetworks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportBlockedPublicNetworks exposes blockedPublicNetworks.
|
||||||
|
func ExportBlockedPublicNetworks() []*net.IPNet {
|
||||||
|
return blockedPublicNetworks
|
||||||
|
}
|
||||||
|
|
||||||
// ExportIsForwardableHeader exposes isForwardableHeader.
|
// ExportIsForwardableHeader exposes isForwardableHeader.
|
||||||
func ExportIsForwardableHeader(name string) bool {
|
func ExportIsForwardableHeader(name string) bool {
|
||||||
return isForwardableHeader(name)
|
return isForwardableHeader(name)
|
||||||
|
|||||||
+46
-25
@@ -25,8 +25,16 @@ var (
|
|||||||
errNoIPs = errors.New(
|
errNoIPs = errors.New(
|
||||||
"hostname resolved to no IP addresses",
|
"hostname resolved to no IP addresses",
|
||||||
)
|
)
|
||||||
errBlockedIP = errors.New(
|
// ErrBlockedPrivateOrReservedIP reports an address in the
|
||||||
"blocked private, reserved or cloud metadata address",
|
// default blocklist's private and reserved ranges,
|
||||||
|
// blockedNetworks.
|
||||||
|
ErrBlockedPrivateOrReservedIP = errors.New(
|
||||||
|
"blocked private or reserved address",
|
||||||
|
)
|
||||||
|
// errBlockedPublicMetadata reports a public address on the
|
||||||
|
// default blocklist, one in blockedPublicNetworks.
|
||||||
|
errBlockedPublicMetadata = errors.New(
|
||||||
|
"blocked cloud metadata address",
|
||||||
)
|
)
|
||||||
errBlockedMetadata = errors.New(
|
errBlockedMetadata = errors.New(
|
||||||
"blocked link-local or cloud instance metadata " +
|
"blocked link-local or cloud instance metadata " +
|
||||||
@@ -37,22 +45,32 @@ var (
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// blockedNetworks is the default blocklist: the private and
|
// blockedNetworks and blockedPublicNetworks together are the
|
||||||
// reserved IP ranges, plus the public cloud metadata addresses,
|
// default blocklist: the private and reserved IP ranges, plus
|
||||||
// that are blocked to prevent SSRF attacks. An operator can
|
// the public cloud metadata addresses, that are blocked to
|
||||||
// permit specific blocks out of this set with
|
// prevent SSRF attacks. An operator can permit specific blocks
|
||||||
// ALLOWED_EGRESS_CIDRS; see Guard.
|
// out of this set with ALLOWED_EGRESS_CIDRS; see Guard.
|
||||||
//
|
//
|
||||||
// A public address belongs on the default blocklist only if it
|
// blockedNetworks holds the private and reserved IP ranges.
|
||||||
// hands credentials, user data or bootstrap material to whatever
|
|
||||||
// can reach it, without the caller presenting anything. A
|
|
||||||
// provider's other public addresses are not refused, since
|
|
||||||
// reaching them can be legitimate and no list of them could be
|
|
||||||
// complete.
|
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // package-level network list is appropriate here
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
||||||
var blockedNetworks []*net.IPNet
|
var blockedNetworks []*net.IPNet
|
||||||
|
|
||||||
|
// blockedPublicNetworks holds the default blocklist's public
|
||||||
|
// addresses, kept apart from blockedNetworks so that they are
|
||||||
|
// refused as cloud metadata addresses, never as private or
|
||||||
|
// reserved ones.
|
||||||
|
//
|
||||||
|
// A public address belongs on the default blocklist only if it
|
||||||
|
// hands credentials, user data or bootstrap material to whatever
|
||||||
|
// can reach it, without the caller presenting anything; it goes
|
||||||
|
// in this list. A provider's other public addresses are not
|
||||||
|
// refused, since reaching them can be legitimate and no list of
|
||||||
|
// them could be complete.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
||||||
|
var blockedPublicNetworks []*net.IPNet
|
||||||
|
|
||||||
// alwaysBlockedNetworks are the ranges no configuration can
|
// alwaysBlockedNetworks are the ranges no configuration can
|
||||||
// open: the link-local blocks and the cloud instance metadata
|
// open: the link-local blocks and the cloud instance metadata
|
||||||
// endpoints that live outside them. Reaching one is credential
|
// endpoints that live outside them. Reaching one is credential
|
||||||
@@ -88,8 +106,8 @@ var blockedNetworks []*net.IPNet
|
|||||||
// when it clears both halves. Nothing in this list can be
|
// when it clears both halves. Nothing in this list can be
|
||||||
// reopened, so putting a public address here leaves the operator
|
// reopened, so putting a public address here leaves the operator
|
||||||
// no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS
|
// no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS
|
||||||
// exists to remove. Default-block it in blockedNetworks instead,
|
// exists to remove. Default-block it in blockedPublicNetworks
|
||||||
// which an allowlist can override.
|
// instead, which an allowlist can override.
|
||||||
//
|
//
|
||||||
// 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.
|
||||||
@@ -130,6 +148,9 @@ func init() {
|
|||||||
"::1/128",
|
"::1/128",
|
||||||
"fc00::/7",
|
"fc00::/7",
|
||||||
"fe80::/10",
|
"fe80::/10",
|
||||||
|
})
|
||||||
|
|
||||||
|
blockedPublicNetworks = mustParseCIDRs([]string{
|
||||||
// Azure WireServer, a public address that serves VM credentials.
|
// Azure WireServer, a public address that serves VM credentials.
|
||||||
"168.63.129.16/32",
|
"168.63.129.16/32",
|
||||||
})
|
})
|
||||||
@@ -225,13 +246,6 @@ func matchesAny(networks []*net.IPNet, ip net.IP) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// isBlockedIP checks whether an IP address falls within
|
|
||||||
// the default blocklist, before any operator allowlist is
|
|
||||||
// considered.
|
|
||||||
func isBlockedIP(ip net.IP) bool {
|
|
||||||
return matchesAny(blockedNetworks, ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard makes every SSRF decision in the process.
|
// Guard makes every SSRF decision in the process.
|
||||||
//
|
//
|
||||||
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
|
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
|
||||||
@@ -332,7 +346,8 @@ func (g *Guard) allows(ip net.IP) bool {
|
|||||||
// consulted, so no configured CIDR reaches link-local or a
|
// consulted, so no configured CIDR reaches link-local or a
|
||||||
// cloud metadata endpoint at a non-public address.
|
// cloud metadata endpoint at a non-public address.
|
||||||
// 2. The allowlist is consulted next, so a listed private
|
// 2. The allowlist is consulted next, so a listed private
|
||||||
// network becomes reachable.
|
// network, or a listed public address on the default
|
||||||
|
// blocklist, becomes reachable.
|
||||||
// 3. Everything else keeps the default blocklist's answer.
|
// 3. Everything else keeps the default blocklist's answer.
|
||||||
func (g *Guard) checkIP(ip net.IP) error {
|
func (g *Guard) checkIP(ip net.IP) error {
|
||||||
if matchesAny(alwaysBlockedNetworks, ip) {
|
if matchesAny(alwaysBlockedNetworks, ip) {
|
||||||
@@ -345,9 +360,15 @@ func (g *Guard) checkIP(ip net.IP) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if isBlockedIP(ip) {
|
if matchesAny(blockedNetworks, ip) {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"target IP %s: %w", ip, errBlockedIP,
|
"target IP %s: %w", ip, ErrBlockedPrivateOrReservedIP,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchesAny(blockedPublicNetworks, ip) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"target IP %s: %w", ip, errBlockedPublicMetadata,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ const (
|
|||||||
metadataIP = "169.254.169.254"
|
metadataIP = "169.254.169.254"
|
||||||
metadataURL = "http://" + metadataIP + "/latest/meta-data/"
|
metadataURL = "http://" + metadataIP + "/latest/meta-data/"
|
||||||
|
|
||||||
|
// linkLocalIPv4 is the IPv4 link-local block, which holds
|
||||||
|
// metadataIP.
|
||||||
|
linkLocalIPv4 = "169.254.0.0/16"
|
||||||
|
|
||||||
// loopbackHookURL is a target on this host: blocked by
|
// loopbackHookURL is a target on this host: blocked by
|
||||||
// default, reachable only once an operator allowlists
|
// default, reachable only once an operator allowlists
|
||||||
// loopback.
|
// loopback.
|
||||||
@@ -237,7 +241,7 @@ func linkLocalRefusedCases() []metadataAlwaysRefusedCase {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "whole link-local block",
|
name: "whole link-local block",
|
||||||
allow: "169.254.0.0/16",
|
allow: linkLocalIPv4,
|
||||||
target: metadataURL,
|
target: metadataURL,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -412,6 +416,9 @@ func TestGuardAllowlist_AzureWireServerReopenable(t *testing.T) {
|
|||||||
"WireServer must be refused by the default blocklist, "+
|
"WireServer must be refused by the default blocklist, "+
|
||||||
"which an allowlist can override",
|
"which an allowlist can override",
|
||||||
)
|
)
|
||||||
|
require.NotErrorIs(t, err, delivery.ErrBlockedPrivateOrReservedIP,
|
||||||
|
"WireServer is public, not private or reserved",
|
||||||
|
)
|
||||||
|
|
||||||
assertDialRefused(t, defaultGuard, target)
|
assertDialRefused(t, defaultGuard, target)
|
||||||
|
|
||||||
@@ -496,7 +503,7 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
|
|||||||
want := []string{
|
want := []string{
|
||||||
// IPv4 link-local: the 169.254.169.254 metadata
|
// IPv4 link-local: the 169.254.169.254 metadata
|
||||||
// service on AWS, Azure and others.
|
// service on AWS, Azure and others.
|
||||||
"169.254.0.0/16",
|
linkLocalIPv4,
|
||||||
// IPv6 link-local.
|
// IPv6 link-local.
|
||||||
"fe80::/10",
|
"fe80::/10",
|
||||||
// AWS IPv6 IMDS, inside the ULA space an operator may
|
// AWS IPv6 IMDS, inside the ULA space an operator may
|
||||||
@@ -526,6 +533,90 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
|
|||||||
assert.Equal(t, want, got)
|
assert.Equal(t, want, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDefaultBlocklist_PinnedSet pins each list of the default
|
||||||
|
// blocklist on its own, the private and reserved ranges in
|
||||||
|
// blockedNetworks and the public addresses in
|
||||||
|
// blockedPublicNetworks, so moving an entry from one list to the
|
||||||
|
// other fails it. For the first address of each entry it then
|
||||||
|
// checks that the default guard refuses it, and that listing the
|
||||||
|
// entry in ALLOWED_EGRESS_CIDRS opens it unless the unconditional
|
||||||
|
// set holds that address.
|
||||||
|
func TestDefaultBlocklist_PinnedSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// public marks an entry of blockedPublicNetworks; every other
|
||||||
|
// entry belongs in blockedNetworks.
|
||||||
|
tests := []struct {
|
||||||
|
cidr string
|
||||||
|
public bool
|
||||||
|
reopenable bool
|
||||||
|
}{
|
||||||
|
{cidr: "127.0.0.0/8", reopenable: true},
|
||||||
|
{cidr: "10.0.0.0/8", reopenable: true},
|
||||||
|
{cidr: "172.16.0.0/12", reopenable: true},
|
||||||
|
{cidr: "192.168.0.0/16", reopenable: true},
|
||||||
|
{cidr: linkLocalIPv4, reopenable: false},
|
||||||
|
{cidr: "0.0.0.0/8", reopenable: true},
|
||||||
|
{cidr: "100.64.0.0/10", reopenable: true},
|
||||||
|
{cidr: "192.0.0.0/24", reopenable: true},
|
||||||
|
{cidr: "192.0.2.0/24", reopenable: true},
|
||||||
|
{cidr: "198.18.0.0/15", reopenable: true},
|
||||||
|
{cidr: "198.51.100.0/24", reopenable: true},
|
||||||
|
{cidr: "203.0.113.0/24", reopenable: true},
|
||||||
|
{cidr: "224.0.0.0/4", reopenable: true},
|
||||||
|
{cidr: "240.0.0.0/4", reopenable: true},
|
||||||
|
{cidr: "::1/128", reopenable: true},
|
||||||
|
{cidr: "fc00::/7", reopenable: true},
|
||||||
|
{cidr: "fe80::/10", reopenable: false},
|
||||||
|
{cidr: "168.63.129.16/32", public: true, reopenable: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPrivate := make([]string, 0, len(tests))
|
||||||
|
wantPublic := make([]string, 0, len(tests))
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if tt.public {
|
||||||
|
wantPublic = append(wantPublic, tt.cidr)
|
||||||
|
} else {
|
||||||
|
wantPrivate = append(wantPrivate, tt.cidr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gotPrivate := make([]string, 0, len(tests))
|
||||||
|
for _, n := range delivery.ExportBlockedNetworks() {
|
||||||
|
gotPrivate = append(gotPrivate, n.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
gotPublic := make([]string, 0, len(tests))
|
||||||
|
for _, n := range delivery.ExportBlockedPublicNetworks() {
|
||||||
|
gotPublic = append(gotPublic, n.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, wantPrivate, gotPrivate, "blockedNetworks")
|
||||||
|
assert.ElementsMatch(t, wantPublic, gotPublic, "blockedPublicNetworks")
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.cidr, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
prefix := netip.MustParsePrefix(tt.cidr)
|
||||||
|
ip := net.IP(prefix.Addr().AsSlice())
|
||||||
|
|
||||||
|
require.Error(t,
|
||||||
|
delivery.NewTestGuard().ExportCheckIP(ip),
|
||||||
|
"the default guard must refuse %s", ip,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := delivery.NewTestGuard(prefix).ExportCheckIP(ip)
|
||||||
|
if tt.reopenable {
|
||||||
|
assert.NoError(t, err, "listing %s must open it", tt.cidr)
|
||||||
|
} else {
|
||||||
|
assert.Error(t, err, "listing %s must not open it", tt.cidr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// requireLoopback fails the test unless rawURL's host is a
|
// requireLoopback fails the test unless rawURL's host is a
|
||||||
// loopback address, so the allowlist test cannot silently stop
|
// loopback address, so the allowlist test cannot silently stop
|
||||||
// exercising a blocked range.
|
// exercising a blocked range.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIsBlockedIP_PrivateRanges(t *testing.T) {
|
func TestGuardCheckIP_PrivateRanges(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -56,12 +56,14 @@ func TestIsBlockedIP_PrivateRanges(t *testing.T) {
|
|||||||
"failed to parse IP %s", tt.ip,
|
"failed to parse IP %s", tt.ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
refused := delivery.NewTestGuard().ExportCheckIP(ip) != nil
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
tt.blocked,
|
tt.blocked,
|
||||||
delivery.ExportIsBlockedIP(ip),
|
refused,
|
||||||
"isBlockedIP(%s) = %v, want %v",
|
"default guard refuses %s = %v, want %v",
|
||||||
tt.ip,
|
tt.ip,
|
||||||
delivery.ExportIsBlockedIP(ip),
|
refused,
|
||||||
tt.blocked,
|
tt.blocked,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 two entrypoints, one inactive,
|
||||||
|
// and four targets, one inactive. Three events each reach the three
|
||||||
|
// active targets. Two deliveries failed in the last 24 hours, one 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")
|
||||||
|
path := statsEntrypoint(t, db, wh.ID, true)
|
||||||
|
|
||||||
|
statsEntrypoint(t, db, wh.ID, false)
|
||||||
|
|
||||||
|
first := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
|
||||||
|
seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
|
||||||
|
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
require.NoError(t, db.DB().Model(inactive).
|
||||||
|
Update("active", false).Error)
|
||||||
|
|
||||||
|
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 3)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
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.DeliveryStatusDelivered, now.Add(-time.Minute))
|
||||||
|
|
||||||
|
return wh, events[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedHealthyWebhook adds a webhook with one entrypoint and one target,
|
||||||
|
// both active, and two events, both delivered. 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")
|
||||||
|
path := statsEntrypoint(t, db, wh.ID, true)
|
||||||
|
target := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
|
||||||
|
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 2)
|
||||||
|
|
||||||
|
for _, ev := range events {
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, ev.ID, target.ID),
|
||||||
|
database.DeliveryStatusDelivered, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
return wh, events[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
|
||||||
|
quiet := listWebhook(t, db, "quiet")
|
||||||
|
|
||||||
|
page := renderWebhookList(t, h, sess)
|
||||||
|
|
||||||
|
card, text := listCard(t, page, failing.ID)
|
||||||
|
assert.Contains(t, text, "2 entrypoints, 1 inactive "+
|
||||||
|
"4 targets, 1 inactive "+
|
||||||
|
"3 events within retention "+
|
||||||
|
"Last event "+lastEventText(failingNewest)+" "+
|
||||||
|
"2 failed deliveries in the last 24 hours")
|
||||||
|
assert.Contains(t, card,
|
||||||
|
failedHighlight+">2 failed deliveries in the last 24 hours<")
|
||||||
|
|
||||||
|
card, text = listCard(t, page, healthy.ID)
|
||||||
|
assert.Contains(t, text, "1 entrypoint "+
|
||||||
|
"1 target "+
|
||||||
|
"2 events within retention "+
|
||||||
|
"Last event "+lastEventText(healthyNewest)+" "+
|
||||||
|
"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, "0 entrypoints "+
|
||||||
|
"0 targets "+
|
||||||
|
"0 events within retention "+
|
||||||
|
"No events yet "+
|
||||||
|
"0 failed deliveries in the last 24 hours")
|
||||||
|
assert.NotContains(t, card, failedHighlight)
|
||||||
|
|
||||||
|
card, text = listCard(t, page, quiet.ID)
|
||||||
|
assert.Contains(t, text, "0 entrypoints "+
|
||||||
|
"0 targets "+
|
||||||
|
"0 events within retention "+
|
||||||
|
"No events yet "+
|
||||||
|
"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)
|
||||||
|
|
||||||
|
path := statsEntrypoint(t, db, wh.ID, true)
|
||||||
|
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 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,
|
||||||
|
"1 entrypoint 0 targets 2 events within retention")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
statsEntrypoint(t, db, broken.ID, true)
|
||||||
|
|
||||||
|
brokenDB, err := dbMgr.GetDB(broken.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t,
|
||||||
|
brokenDB.Migrator().DropTable(&database.EventTotals{}))
|
||||||
|
|
||||||
|
quiet := listWebhook(t, db, "quiet")
|
||||||
|
|
||||||
|
page := renderWebhookList(t, h, sess)
|
||||||
|
|
||||||
|
_, text := listCard(t, page, broken.ID)
|
||||||
|
assert.Contains(t, text,
|
||||||
|
"1 entrypoint 0 targets 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")
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@ 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"
|
||||||
@@ -20,9 +22,20 @@ import (
|
|||||||
type WebhookListItem struct {
|
type WebhookListItem struct {
|
||||||
database.Webhook
|
database.Webhook
|
||||||
|
|
||||||
EntrypointCount int64
|
EntrypointCount int
|
||||||
TargetCount int64
|
InactiveEntrypointCount int
|
||||||
EventCount int64
|
TargetCount int
|
||||||
|
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.
|
||||||
@@ -160,7 +173,18 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
items := h.buildWebhookListItems(webhooks)
|
items, err := h.buildWebhookListItems(webhooks)
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error(
|
||||||
|
"failed to list webhooks", "error", err,
|
||||||
|
)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Webhooks": items,
|
"Webhooks": items,
|
||||||
@@ -170,36 +194,115 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildWebhookListItems builds list items with counts.
|
// buildWebhookListItems builds the list's entry for each webhook. It
|
||||||
|
// 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 {
|
) ([]WebhookListItem, error) {
|
||||||
items := make([]WebhookListItem, len(webhooks))
|
items := make([]WebhookListItem, len(webhooks))
|
||||||
|
since := time.Now().Add(-longWindow)
|
||||||
|
|
||||||
for i := range webhooks {
|
for i := range webhooks {
|
||||||
items[i].Webhook = webhooks[i]
|
item := &items[i]
|
||||||
|
item.Webhook = webhooks[i]
|
||||||
|
|
||||||
h.db.DB().Model(&database.Entrypoint{}).Where(
|
var err error
|
||||||
"webhook_id = ?", webhooks[i].ID,
|
|
||||||
).Count(&items[i].EntrypointCount)
|
|
||||||
|
|
||||||
h.db.DB().Model(&database.Target{}).Where(
|
item.EntrypointCount, item.InactiveEntrypointCount, err =
|
||||||
"webhook_id = ?", webhooks[i].ID,
|
h.countWithInactive(&database.Entrypoint{}, item.ID)
|
||||||
).Count(&items[i].TargetCount)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if h.dbMgr.DBExists(webhooks[i].ID) {
|
item.TargetCount, item.InactiveTargetCount, err =
|
||||||
webhookDB, err := h.dbMgr.GetDB(
|
h.countWithInactive(&database.Target{}, item.ID)
|
||||||
webhooks[i].ID,
|
if err != nil {
|
||||||
|
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 {
|
|
||||||
webhookDB.Model(
|
item.EventsUnreadable = true
|
||||||
&database.Event{},
|
|
||||||
).Count(&items[i].EventCount)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
@@ -1578,11 +1681,22 @@ func (h *Handlers) validateTargetURL(
|
|||||||
"url", delivery.MaskURL(targetURL),
|
"url", delivery.MaskURL(targetURL),
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
http.Error(
|
|
||||||
w,
|
msg := "Invalid target URL: " + err.Error()
|
||||||
"Invalid target URL: "+err.Error(),
|
|
||||||
http.StatusBadRequest,
|
// Only a private or reserved address's refusal says how
|
||||||
)
|
// to allow it. Metadata refusals never do: link-local and
|
||||||
|
// the other unconditional metadata addresses cannot be
|
||||||
|
// opened, and the default blocklist's public addresses,
|
||||||
|
// which listing does open, hand out credentials.
|
||||||
|
if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) {
|
||||||
|
msg += ". Private and reserved addresses are refused " +
|
||||||
|
"by default; the server's ALLOWED_EGRESS_CIDRS " +
|
||||||
|
"setting allows named networks (see \"Allowing " +
|
||||||
|
"egress to your own network\" in the README)."
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, msg, http.StatusBadRequest)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// privateRefusalHint is the sentence that tells an operator a private
|
||||||
|
// destination is refused on purpose, and how to allow one.
|
||||||
|
const privateRefusalHint = "Private and reserved addresses are " +
|
||||||
|
"refused by default; the server's ALLOWED_EGRESS_CIDRS setting " +
|
||||||
|
"allows named networks (see \"Allowing egress to your own " +
|
||||||
|
"network\" in the README)."
|
||||||
|
|
||||||
|
// TestTargetRefusal_PrivateDestinationSaysHowToAllowIt covers both
|
||||||
|
// target types that take a URL, on add and on edit.
|
||||||
|
func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
targetTypes := []database.TargetType{
|
||||||
|
database.TargetTypeHTTP,
|
||||||
|
database.TargetTypeSlack,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, targetType := range targetTypes {
|
||||||
|
t.Run(string(targetType), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
targetsPath := "/hook/" + webhook.ID + "/targets"
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "private")
|
||||||
|
form.Set("type", string(targetType))
|
||||||
|
form.Set("url", editBlockedURL)
|
||||||
|
|
||||||
|
added := serveTarget(
|
||||||
|
env, http.MethodPost, targetsPath, form,
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, added.Code)
|
||||||
|
assert.Contains(
|
||||||
|
t, added.Body.String(), privateRefusalHint,
|
||||||
|
)
|
||||||
|
|
||||||
|
form.Set("url", editOriginalURL)
|
||||||
|
|
||||||
|
created := serveTarget(
|
||||||
|
env, http.MethodPost, targetsPath, form,
|
||||||
|
)
|
||||||
|
require.Equal(
|
||||||
|
t, http.StatusSeeOther, created.Code,
|
||||||
|
created.Body.String(),
|
||||||
|
)
|
||||||
|
|
||||||
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
|
require.Len(t, targets, 1)
|
||||||
|
|
||||||
|
form.Set("url", editBlockedURL)
|
||||||
|
|
||||||
|
edited := submitTargetEdit(
|
||||||
|
env, webhook.ID, targets[0].ID, form,
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, edited.Code)
|
||||||
|
assert.Contains(
|
||||||
|
t, edited.Body.String(), privateRefusalHint,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetRefusal_MetadataDestinationDoesNotSayHowToAllowIt: no
|
||||||
|
// setting opens a link-local address, and Azure's WireServer hands out
|
||||||
|
// VM credentials, so neither refusal points at the setting.
|
||||||
|
func TestTargetRefusal_MetadataDestinationDoesNotSayHowToAllowIt(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
metadataURLs := map[string]string{
|
||||||
|
"link-local": "http://169.254.169.254/latest/meta-data/",
|
||||||
|
"wireserver": "http://168.63.129.16/?comp=versions",
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, metadataURL := range metadataURLs {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "metadata")
|
||||||
|
form.Set("type", string(database.TargetTypeHTTP))
|
||||||
|
form.Set("url", metadataURL)
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodPost,
|
||||||
|
"/hook/"+webhook.ID+"/targets", form,
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
assert.NotContains(
|
||||||
|
t, w.Body.String(), privateRefusalHint,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
//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
-5
@@ -1,16 +1,15 @@
|
|||||||
#!/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 package
|
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The
|
||||||
# is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy
|
# extracted file is not committed. script/test, make build and make dev run
|
||||||
# forbids eval. The extracted file is not committed. script/test, make
|
# this first.
|
||||||
# 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-csp-3.14.9.tgz package/dist/cdn.min.js \
|
tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \
|
||||||
>static/js/alpine.min.js
|
>static/js/alpine.min.js
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/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 "$@"
|
|
||||||
@@ -57,73 +57,3 @@
|
|||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{{define "navbar"}}
|
{{define "navbar"}}
|
||||||
<nav class="app-bar" x-data="collapsible">
|
<nav class="app-bar" x-data="{ open: false }">
|
||||||
<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="toggle" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
|
<button @click="open = !open" 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="closed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
<path x-show="!open" 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>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||||
<div class="flex justify-between items-center mt-2">
|
<div class="flex justify-between items-center mt-2">
|
||||||
@@ -28,10 +28,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" x-data="collapsible">
|
<div class="card">
|
||||||
<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="toggle" class="btn-text text-sm">
|
<button @click="showAddEntrypoint = !showAddEntrypoint" 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>
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add entrypoint form -->
|
<!-- Add entrypoint form -->
|
||||||
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
<div x-show="showAddEntrypoint" 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">
|
||||||
@@ -87,10 +87,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Targets -->
|
<!-- Targets -->
|
||||||
<div class="card" x-data="collapsible">
|
<div class="card">
|
||||||
<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="toggle" class="btn-text text-sm">
|
<button @click="showAddTarget = !showAddTarget" 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>
|
||||||
@@ -99,42 +99,42 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add target form -->
|
<!-- Add target form -->
|
||||||
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="targetForm" @pageshow.window="readType" class="space-y-3">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" 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" @change="readType" class="input text-sm w-32">
|
<select name="type" x-model="targetType" 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="isHttp">
|
<div x-show="targetType === 'http'">
|
||||||
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="notHttp" class="input text-sm">
|
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm">
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isHttp">
|
<div x-show="targetType === 'http'">
|
||||||
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="notHttp" class="input text-sm"></textarea>
|
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="targetType !== 'http'" 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="isHttp" class="flex gap-2 items-center">
|
<div x-show="targetType === 'http'" 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="notHttp" class="input text-sm w-24">
|
<input type="number" name="timeout" min="0" max="300" :disabled="targetType !== 'http'" class="input text-sm w-24">
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isHttp">
|
<div x-show="targetType === 'http'">
|
||||||
<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="isSlack">
|
<div x-show="targetType === 'slack'">
|
||||||
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="notSlack" class="input text-sm">
|
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" 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="isDatabase">
|
<div x-show="targetType === 'database'">
|
||||||
<input type="text" name="expiry" placeholder="never" :disabled="notDatabase" class="input text-sm">
|
<input type="text" name="expiry" placeholder="never" :disabled="targetType !== 'database'" 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>
|
||||||
|
|||||||
@@ -23,8 +23,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="collapsible">
|
<div class="p-4" x-data="{ open: false }">
|
||||||
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
|
<div class="flex items-center justify-between cursor-pointer" @click="open = !open">
|
||||||
<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>
|
||||||
@@ -43,7 +43,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="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="{ 'rotate-180': open }" 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>
|
||||||
@@ -71,8 +71,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="collapsible">
|
<div class="py-2" x-data="{ attempts: false }">
|
||||||
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
|
<div class="flex items-center justify-between cursor-pointer" @click="attempts = !attempts">
|
||||||
<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>
|
||||||
@@ -86,13 +86,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="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="{ 'rotate-180': attempts }" 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="open" x-cloak class="mt-2 space-y-2">
|
<div x-show="attempts" 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}}
|
||||||
|
|||||||
@@ -27,10 +27,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="badge-info">Retention: {{.RetentionLabel}}</span>
|
<span class="badge-info">Retention: {{.RetentionLabel}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-6 mt-4 text-sm text-gray-500">
|
<div class="flex flex-wrap gap-6 mt-4 text-sm text-gray-500">
|
||||||
<span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}</span>
|
<span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}{{if .InactiveEntrypointCount}}, {{.InactiveEntrypointCount}} inactive{{end}}</span>
|
||||||
<span>{{.TargetCount}} target{{if ne .TargetCount 1}}s{{end}}</span>
|
<span>{{.TargetCount}} target{{if ne .TargetCount 1}}s{{end}}{{if .InactiveTargetCount}}, {{.InactiveTargetCount}} inactive{{end}}</span>
|
||||||
<span>{{.EventCount}} event{{if ne .EventCount 1}}s{{end}}</span>
|
{{if .EventsUnreadable}}
|
||||||
|
<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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user