28 Commits
Author SHA1 Message Date
clawbot 385fbc1a6a Send fx's own events through the service's logger (closes #183)
check / check (push) Waiting to run
fx printed its dependency graph and lifecycle hooks through its own console logger on standard error, so an operator shipping the JSON log to a collector got a second shape on a second stream for every start. The production app now passes fx.WithLogger with a small FxLogger in internal/logger that writes fx's events through the service's logger: graph events at debug, lifecycle at info, failures at error. Its constructor takes the configuration, so DEBUG=true applies before fx replays the events it held back. go.uber.org/fx moves from v1.20.1 to v1.24.0. Tests keep fx.NopLogger. The README says a failure before the logger exists, and the Go runtime's own output, still go to standard error as plain text.

Model: opus-5-5
2026-10-02 17:22:05 +02:00
clawbot c22ca6218e Pin the rate-limit key for an empty RemoteAddr (closes #168)
check / check (push) Waiting to run
A request whose RemoteAddr is empty has no peer identity, so the rate limiters' key falls back to the raw empty string and every such request shares one bucket: it fails closed rather than giving each its own. net/http always fills RemoteAddr for a TCP listener, so normal serving never reaches this. The behaviour is unchanged and now deliberate: a test pins the shared key, and a one-sentence comment at the fallback tells the empty case apart from a Unix-socket listener, where every peer legitimately carries the same address.

Model: opus-5-5
2026-10-02 17:09:23 +02:00
clawbot 0ccb01cada Close the retention follow-ups from the August review (closes #99)
check / check (push) Waiting to run
Follow-ups from an August review of the retention bounds, each checked against the current tree. A test now pins that a retention value above the keep-forever sentinel is stored as the sentinel. The form's retention parser returns its message directly, so the two error values that were never compared, and the function that mapped them to messages, are gone. The sweep's own keep-forever skip, which duplicated the check in retentionCutoff, is removed; the cutoff is now asked before the webhook's database is opened. The create-form refill test uses HTML-special characters and checks they come back escaped. The README item was already settled; handling for rows made by hand is declined.

Model: opus-5-5
2026-10-02 16:50:34 +02:00
clawbot 1f22b30de3 Log the client address next to the peer address (closes #270)
check / check (push) Waiting to run
Behind a trusted proxy every log line named only the proxy, so abuse could not be traced from webhooker's own logs although the rate limiters already knew the client. The access log, the rate-limit rejection lines, the CSRF warning and the receiver's request line now carry clientIP next to remoteIP. remoteIP still means the connecting peer; clientIP is the address the rate limiters key on, the forwarded client when the peer is inside TRUSTED_PROXIES, worked out once per request by the same code. The README says the field is only as trustworthy as TRUSTED_PROXIES. The access log's 2,560-byte line ceiling holds with the field charged, and a size case with an oversized X-Forwarded-For pins it.

Model: opus-5-5
2026-10-02 16:50:22 +02:00
clawbot debe588bba Seed the retention tests 50 rows per insert, not 500 (closes #198)
check / check (push) Waiting to run
Three retention tests made internal/database the slowest test package, mostly by seeding thousands of rows 500 per insert: the SQLite driver finds each parameter's value by scanning the statement's arguments from the first until it reaches that parameter's, so binding grows with the square of the parameter count. They now seed the same rows 50 per insert, about three times faster; no test case or assertion changes. The package drops from 13 to 22s to about 7s. What keeps make test above the 20s target is now mostly the cold -race compile of the tree, which moves with host load. The 90s per-package timeout stays; script/test's header records the new figures.

Model: opus-5-5
2026-10-02 16:27:00 +02:00
clawbot e8379272ae Load Alpine's CSP build so the UI's directives run (closes #371)
check / check (push) Waiting to run
Every page's Content-Security-Policy forbids eval, which the standard Alpine.js build needs, so no directive ran in a browser: both add forms on the webhook page showed open, and events in the event log could not be collapsed. The UI now loads Alpine's CSP build (@alpinejs/csp 3.14.9 in 3p/); the policy is unchanged. Each directive names a property or method of a component registered in static/js/app.js (collapsible, targetForm), and each card holds its own x-data. A browser test, built only with the browser tag, loads the webhook page and the event log under the real headers; make test-browser runs it in Docker. New test-only dependency chromedp, which raises golang.org/x/sys to 0.47.0.

Model: opus-5-5
2026-10-02 16:09:03 +02:00
clawbot 3e209bfe4e Drive the archive reopen debounce test from a clock (closes #190)
check / check (push) Waiting to run
The archive writer's reopen debounce test made two writes that had to land inside the real 2-second window, then slept 2.1 seconds to cross it, so a slow host could turn correct code red. The archive writer now reads the time for its reopen debounce from a clock field, time.Now in production, and the test moves that clock instead of sleeping: two writes at one instant open the file once, and a write one debounce later closes and reopens it once. Removing the debounce check fails the test. This was the last test whose result depended on real elapsed time.

Model: opus-5-5
2026-10-02 16:03:01 +02:00
clawbot 88b961c115 Keep each model's parent out of its JSON (closes #177)
check / check (push) Waiting to run
Every reference from a model to the record it belongs to (a target's or entrypoint's webhook, a webhook's or API key's user, an event's webhook and entrypoint, a delivery's event and target, a delivery result's delivery) is now tagged json:"-", so a preloaded model can be marshalled without the encoder recursing between parent and child. References to child records stay. Tests marshal each of the nine references set, preloaded where it matters, and check the parent's id is absent, so deleting or restoring any one tag fails a test; preloading still fills each reference.

Model: opus-5-5
2026-10-02 15:43:06 +02:00
clawbot dc9deda173 Write a form-error page's status only after it renders (closes #128)
check / check (push) Waiting to run
Six form-error paths (the login error and the webhook form's validation and name-taken branches) called WriteHeader before rendering, so if the form page's own template failed, the answer kept its 400 or 409 status with an error body instead of a 500. The new renderTemplateStatus renders into the buffer and writes the status only after the page has rendered; renderTemplate sends 200 through it, and those handlers pass their status to it. No handler calls WriteHeader before a render. Login-form tests show the 400 page still renders in full and a failing template answers 500.

Model: opus-5-5
2026-10-02 15:34:01 +02:00
clawbot c706199389 Name the CI build step after what the image runs (closes #175)
check / check (push) Waiting to run
The workflow's build step was named "Build Docker image (runs make check)", but the image has never run make check. It is now named for what the image runs: make fmt-check, golangci-lint, make test and make build. No other step name or comment in the workflow names something it does not run. In the README, the Prerequisites bullet now says Docker is needed for make lint and so for make check, and the sentence about what runs on the host is limited to the steps make check runs.

Model: opus-5-5
2026-10-02 15:15:08 +02:00
clawbot d52cac1ec6 Toggle only a target's active state, so it cannot undo an edit (closes #431)
check / check (push) Waiting to run
The target toggle loaded the target, flipped its active flag and saved the whole row, so an edit of the same target's name or settings saved in between was written back over and lost, although it reported success. The toggle now updates only the active column, so it can no longer undo an edit. A test saves an edit just after the toggle has read the target and shows the edit survives and the state flips. The entrypoint toggle is unchanged, since an entrypoint has no edit form.

Model: opus-5-5
2026-10-02 14:43:00 +02:00
clawbot 0f9b68a0e8 Build the middleware test cookie stores with the production constructor (closes #154)
check / check (push) Waiting to run
The middleware tests built their cookie stores by setting store.Options by hand, which left the securecookie codecs at the library's 30-day default instead of the 7-day cap production sets, an invisible divergence that would outlive the next change to store construction. The test store constructor moves from internal/session/export_test.go into internal/session/testing.go so other packages can reach it, and the two middleware test helpers build their stores through it. No test in the repo builds a cookie store by hand any more, and no assertion changes.

Model: opus-5-5
2026-10-02 14:30:51 +02:00
clawbot 8cf5acaf1d Justify the handlers tests' start limit with a measurement (closes #225)
check / check (push) Waiting to run
The internal/handlers tests were load-fragile because every test app hashed the admin password at 64 MB; that went with the cheaper test hashing already on next, and measuring under the host's real load found nothing left to fix in how the tests run. The comment on newTestApp now says its start limit, fx's default, is there to catch a start that hangs, and that the slowest measured start is far inside it. The header of script/test gives current figures in place of ones from before that change. Neither limit changes, and no test changes.

Model: opus-5-5
2026-10-02 14:08:54 +02:00
clawbot 9ade217222 Show each webhook's activity in the webhook list (closes #394)
check / check (push) Waiting to run
Each entry in the webhook list at /hooks now shows when its last event arrived (or "No events yet"), how many of its deliveries failed in the last 24 hours, in red when not zero, and how many entrypoints and targets are inactive, as in "4 targets, 1 inactive". The figures come from the event totals row and the statistics pane's own query, so the list no longer counts every stored event and its event count matches the pane's. A webhook whose event database cannot be read says so in its entry; the rest of the list still shows. A failed read of entrypoints or targets from the main database now returns an error page instead of showing zero.

Model: opus-5-5
2026-10-02 13:57:43 +02:00
clawbot 5551f75251 Say at the receiver route where its 1 MB body cap lives (closes #173)
check / check (push) Waiting to run
The receiver route /h/{uuid} has no MaxBodySize middleware, unlike the page route groups; its 1 MB cap is enforced in the handler, which owns the response senders see. Nothing at the route said so, so a reader could take the receiver for uncapped or remove the only bound on the one unauthenticated endpoint. The route registration and the handler's body-reading function now say where the cap lives and why. A routing test sends a body of exactly 1 MB and one a byte over through the production router and pins that only the second is refused, with the handler's 413 and message.

Model: opus-5-5
2026-10-02 13:40:00 +02:00
clawbot fd036774f9 Name each database target's archive for its webhook and target (closes #376)
check / check (push) Waiting to run
Each database target now writes its own archive file, archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, named by delivery.ArchiveFileName, in place of one archive per webhook keyed on its UUID. Renaming a webhook or a target renames its archive files (with any -wal and -shm) before the new name is saved, never over an existing file, and moves every one back if a rename or the save fails. The webhook edit, the target edit and target creation share one lock so no two interleave. Deleting a webhook or target leaves its files on disk. Nothing looks for the old archive-WEBHOOKID.db files. The README gives the naming and the recovery steps.

Model: opus-5-5
2026-10-02 13:28:49 +02:00
clawbot 21aafbf928 Remove the no-op MAINTENANCE_MODE setting (closes #317)
check / check (push) Waiting to run
MAINTENANCE_MODE did nothing but make the healthcheck JSON report maintenanceMode: true; no request was ever served differently, so an operator who set it expecting requests to be refused got nothing. It is removed from the configuration, the startup configuration log line, the healthcheck JSON, the README and the Settings page, together with the uncalled Server.MaintenanceMode method and the healthcheck's dependency on the configuration. A leftover value in an environment is ignored like any other unknown variable.

Model: opus-5-5
2026-10-02 13:27:43 +02:00
clawbot c87b469dcd Let a handler flush or set a write deadline through the access log and metrics (closes #191)
check / check (push) Waiting to run
The access log's response writer and the metrics middleware's writer hid the writer beneath them, so a handler's flush, hijack or write deadline set through http.ResponseController failed with "not supported" behind them. The access log's writer now has Unwrap. The metrics middleware calls the library's public Measure with a writer of our own that has Unwrap, in place of std.Handler's writer, so the middleware order and what metrics record are unchanged. A test over a real connection sets a write deadline and flushes, metrics on and off, on a global route and in an admin page group, and fails without either Unwrap.

Model: opus-5-5
2026-10-02 13:08:54 +02:00
clawbot b14b27b78b Serve the delivery duration histogram from boot (closes #267)
check / check (push) Waiting to run
The delivery duration histogram was registered only when a delivery first ran, so until then /metrics had no series for it and a dashboard or alert on it saw nothing at all rather than zero. Each of the four target types now has its series registered at boot with zero counts, so the histogram is served from the first scrape. Tests pin that all four series are present before any delivery, on the metrics registry and through the production router.

Model: opus-5-5
2026-10-02 12:45:05 +02:00
clawbot 287e47df7f Add a read-only Settings page for the loaded configuration (closes #402)
check / check (push) Waiting to run
A new page at /settings, linked from the navigation bar and behind the login, lists every configuration field the server loaded at startup: its environment variable, the README table's description, and the value in effect. METRICS_PASSWORD and SENTRY_DSN show only as set or not set; their values are replaced before rendering and never reach the template. The route is GET only and its group is built like the other admin page groups. Tests set the credentials one at a time so each value shown is checked against its own field and a set secret never appears in the page.

Model: opus-5-5
2026-10-02 12:30:21 +02:00
clawbot 8b5541734e Run script/test quietly with coverage, rerun failed tests verbosely (closes #315)
check / check (push) Waiting to run
script/test now runs the suite once with -race -cover and no -v. On a failure it reruns only the failed top-level tests, with -v, in the packages that failed, then exits non-zero whatever the rerun shows. A green run stays quiet, and a red one ends with the failing tests' verbose output, which the Docker build's 2 MiB log limit can hold; a verbose rerun of every package would pass that limit again. A failure that names no test (a build error, a timeout) skips the rerun, since the quiet run already prints that package's output. The timeout and parallelism are unchanged.

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

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

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

Model: opus-5-5
2026-10-02 11:08:38 +02:00
clawbot 5b1d283d06 Say what each action did in a one-line notice (closes #383)
check / check (push) Waiting to run
Saving, deleting, activating or deactivating a webhook, entrypoint or target, and signing out, now land on their page with a one-line notice such as "Webhook deleted." or "Signed out.". The redirect carries a fixed code that maps to fixed text; an unknown code shows nothing, so nothing from the URL is ever echoed. One partial in the page layout shows the notice on every page, and replay and resubmit now use the same codes and partial. Error pages show no notice.

Model: opus-5-5
2026-10-02 10:30:31 +02:00
clawbot b78abdc9da Drop TODO.md's stale docs-only cache caveat (closes #421)
check / check (push) Waiting to run
Since the build context carries .git and the CI fingerprint is the hash of the commit being checked, every commit's check runs the build, docs-only commits included. TODO.md's caveat that a docs-only commit replays from the layer cache was no longer true, and the README's "CI gate honesty" section already says how a check runs, so the paragraph is removed.

Model: opus-5-5
2026-10-02 10:14:40 +02:00
clawbot c23ffbac65 Widen the webhook page by half so an entrypoint URL fits on one line (closes #350)
check / check (push) Waiting to run
The webhook page's maximum width goes from 72rem (1152 px) to 108rem (1728 px), half again as wide, so an entrypoint URL stays on one line in 1920- and 1440-pixel windows; the statistics pane and both columns widen with it. The title row now wraps, so a phone-width window no longer scrolls sideways.

The width is an inline style: static/css/style.css is linked by no page, and the committed Tailwind stylesheet has no class that wide. The webhook list, the event log, the navbar and the footer stay at 72rem.

Model: opus-5-5
2026-10-02 09:49:33 +02:00
clawbot 2ac4d4d793 Send the build version in the outbound User-Agent (closes #313)
check / check (push) Waiting to run
The http and slack targets sent the constant User-Agent webhooker/1.0. They now send webhooker/ followed by the version the build stamped, the same value the footer shows, built in one place on the delivery engine. User-Agent stays a reserved header and is still set after the target's configured headers, so a configured one cannot override it. Tests check the header each target sends against a known version, and that a configured User-Agent is replaced.

Model: opus-5-5
2026-10-02 09:48:34 +02:00
clawbot eb4c4cc849 Serve /metrics from a registry of its own (closes #227)
check / check (push) Waiting to run
A second metrics-enabled router in one process panicked on a duplicate collector registration, because every collector registered on Prometheus's global default registry. metrics.NewRegistry now builds one registry with the Go runtime and process collectors; fx provides it and the delivery metric set built on it. The middleware builds its HTTP recorder once on that registry (NewForTest on a fresh one), the engine and handlers take the metric set from fx, and nothing registers on the global default any more.

/metrics is served from the new registry with the same series names, labels and auth. A test builds two metrics-enabled routers in one process.

Model: opus-5-5
2026-10-02 09:06:20 +02:00
102 changed files with 4498 additions and 854 deletions
+1 -1
View File
@@ -33,5 +33,5 @@ jobs:
# report success from cache. # report success from cache.
run: git rev-parse HEAD > .ci-fingerprint run: git rev-parse HEAD > .ci-fingerprint
- name: Build Docker image (runs make check) - name: Build Docker image (runs make fmt-check, golangci-lint, make test, make build)
run: script/cibuild run: script/cibuild
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -26,7 +26,7 @@ COPY . .
# Dockerfile.lint, including --network=none (see its header for why). # Dockerfile.lint, including --network=none (see its header for why).
RUN make fmt-check RUN make fmt-check
RUN --network=none golangci-lint config verify --config .golangci.yml RUN --network=none golangci-lint config verify --config .golangci.yml
RUN --network=none golangci-lint run --config .golangci.yml ./... RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
# Build stage # Build stage
# golang:1.26.1-bookworm (Debian-based), 2026-03-17 # golang:1.26.1-bookworm (Debian-based), 2026-03-17
+29
View File
@@ -0,0 +1,29 @@
# Browser test image, built by script/test-browser (make test-browser). It
# runs the test in internal/server that loads the pages in a headless
# browser under the real Content-Security-Policy. That test is built only
# with the browser build tag, so make test leaves it out. Here the browser
# comes from a digest-pinned image, and if it is missing the test fails.
# golang:1.26.1-bookworm, 2026-03-17: the builder stage's image in Dockerfile.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# The test binary embeds the templates and static files, so the browser
# stage needs nothing else. -p 4 keeps the compile's memory down, as in
# script/test.
RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
# browser is on PATH as headless-shell, where the test's browser library
# looks for it.
FROM chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04 AS browser
COPY --from=build /browser.test /browser.test
RUN /browser.test -test.v -test.timeout 90s -test.run '^TestAlpineRunsUnderTheSecurityPolicy$'
+3 -1
View File
@@ -34,4 +34,6 @@ COPY . .
# `run` silently ignores config keys it does not recognize, so a typo would # `run` silently ignores config keys it does not recognize, so a typo would
# disable a setting without a word. `config verify` is what catches that. # disable a setting without a word. `config verify` is what catches that.
RUN --network=none golangci-lint config verify --config .golangci.yml RUN --network=none golangci-lint config verify --config .golangci.yml
RUN --network=none golangci-lint run --config .golangci.yml ./... # --build-tags browser also lints the browser test, which is built only with
# that tag (make test-browser).
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version .PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css version
# Default target # Default target
.DEFAULT_GOAL := check .DEFAULT_GOAL := check
@@ -33,6 +33,9 @@ assets:
test: test:
@script/test @script/test
test-browser:
@script/test-browser
lint: lint:
@script/lint @script/lint
+195 -115
View File
@@ -19,8 +19,8 @@ before deploying one.
### Prerequisites ### Prerequisites
- Go 1.26.1+ (the version in `go.mod`) - Go 1.26.1+ (the version in `go.mod`)
- Docker (for linting, for the test stage of the CI gate, and for - Docker (for `make lint` and so for `make check`, for the browser test in
containerized deployment) `make test-browser`, for the CI gate, and for containerized deployment)
golangci-lint is not a prerequisite and must not be installed on the golangci-lint is not a prerequisite and must not be installed on the
host: `script/bootstrap` does not install it, and `make lint` runs the host: `script/bootstrap` does not install it, and `make lint` runs the
@@ -58,6 +58,7 @@ make fmt # Format code (gofmt + goimports)
make fmt-check # Fail if gofmt would change anything (writes nothing) make fmt-check # Fail if gofmt would change anything (writes nothing)
make lint # Run golangci-lint in Docker (Dockerfile.lint) make lint # Run golangci-lint in Docker (Dockerfile.lint)
make test # Run tests with race detection make test # Run tests with race detection
make test-browser # Run the browser test in Docker (Dockerfile.browser)
make check # test + lint + fmt-check (CI gate) make check # test + lint + fmt-check (CI gate)
make build # Build binary to bin/webhooker (version-stamped) make build # Build binary to bin/webhooker (version-stamped)
make version # Print the version this checkout would stamp make version # Print the version this checkout would stamp
@@ -135,7 +136,6 @@ TTY detection, and security headers are always applied.
| `BIND_ADDRESS` | IP address the HTTP listener binds. Loopback by default, so the cleartext listener is not published on every interface. The Docker image ships `0.0.0.0` instead. See [Bind address](#bind-address) | `127.0.0.1` (image: `0.0.0.0`) | | `BIND_ADDRESS` | IP address the HTTP listener binds. Loopback by default, so the cleartext listener is not published on every interface. The Docker image ships `0.0.0.0` instead. See [Bind address](#bind-address) | `127.0.0.1` (image: `0.0.0.0`) |
| `DATA_DIR` | Directory for all SQLite databases | `/var/lib/webhooker` | | `DATA_DIR` | Directory for all SQLite databases | `/var/lib/webhooker` |
| `DEBUG` | Enable debug logging | `false` | | `DEBUG` | Enable debug logging | `false` |
| `MAINTENANCE_MODE` | Report `maintenanceMode: true` in the healthcheck JSON. It does not change how any request is served — no maintenance page exists | `false` |
| `METRICS_USERNAME` | Basic auth username for `/metrics`. Must be set together with `METRICS_PASSWORD`; one without the other fails startup | `""` | | `METRICS_USERNAME` | Basic auth username for `/metrics`. Must be set together with `METRICS_PASSWORD`; one without the other fails startup | `""` |
| `METRICS_PASSWORD` | Basic auth password for `/metrics`. Must be set together with `METRICS_USERNAME`; one without the other fails startup | `""` | | `METRICS_PASSWORD` | Basic auth password for `/metrics`. Must be set together with `METRICS_USERNAME`; one without the other fails startup | `""` |
| `SENTRY_DSN` | Sentry error reporting DSN. Unset leaves error reporting off; a value the Sentry SDK cannot parse fails startup rather than serving with reporting silently off | `""` | | `SENTRY_DSN` | Sentry error reporting DSN. Unset leaves error reporting off; a value the Sentry SDK cannot parse fails startup rather than serving with reporting silently off | `""` |
@@ -145,6 +145,11 @@ TTY detection, and security headers are always applied.
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) | | `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) | | `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
The Settings page of the web UI (`/settings`, behind the login) lists
every one of these with the value the running server loaded. It is
read-only, and it shows `METRICS_PASSWORD` and `SENTRY_DSN` only as
set or not set, never their values.
#### Allowing egress to your own network #### Allowing egress to your own network
By default every delivery target must resolve to a public address. The By default every delivery target must resolve to a public address. The
@@ -158,19 +163,20 @@ WireServer, which serves an Azure VM its credentials. Because it is a
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it. public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it.
That is all the default blocklist covers: the IPv4 private and reserved That is all the default blocklist covers: the IPv4 private and reserved
ranges; of IPv6, only loopback (`::1`), unique local addresses ranges; of IPv6, only loopback (`::1`), the unspecified address (`::`),
(`fc00::/7`) and link-local addresses (`fe80::/10`); and certain public unique local addresses (`fc00::/7`), link-local addresses (`fe80::/10`),
addresses. A public address belongs on the default blocklist only if it multicast (`ff00::/8`) and documentation space (`2001:db8::/32`); and
hands credentials, user data or bootstrap material to whatever can reach certain public addresses. A public address belongs on the default
it, without the caller presenting anything. A provider's other public blocklist only if it hands credentials, user data or bootstrap material
addresses are not refused. IBM Cloud, for example, serves its package to whatever can reach it, without the caller presenting anything. A
mirrors, time servers and object storage on `161.26.0.0/16`, and the provider's other public addresses are not refused. IBM Cloud, for
private endpoints of its own cloud services on `166.8.0.0/14`. Neither example, serves its package mirrors, time servers and object storage on
range hands out credentials that way: the token service among those `161.26.0.0/16`, and the private endpoints of its own cloud services on
endpoints issues a token only in exchange for something the caller `166.8.0.0/14`. Neither range hands out credentials that way: the token
presents, such as an API key. Reaching these services can be a service among those endpoints issues a token only in exchange for
legitimate delivery, and every cloud has some, so a partial list would something the caller presents, such as an API key. Reaching these
promise coverage it does not give. services can be a legitimate delivery, and every cloud has some, so a
partial list would promise coverage it does not give.
That default is also inconvenient for the thing webhooker is mostly That default is also inconvenient for the thing webhooker is mostly
for: taking a public webhook and forwarding it to something on your own for: taking a public webhook and forwarding it to something on your own
@@ -210,16 +216,16 @@ Two things this setting cannot do:
the list is always an allowlist; an empty list (the default) means the list is always an allowlist; an empty list (the default) means
every private and reserved range stays refused. Note that every private and reserved range stays refused. Note that
`0.0.0.0/0` gets you most of the way there anyway, per above. `0.0.0.0/0` gets you most of the way there anyway, per above.
- **It cannot open link-local, or a cloud metadata endpoint at a - **It cannot open link-local, the unspecified addresses, or a cloud
non-public address that discloses credentials or user data.** An metadata endpoint at a non-public address that discloses credentials
address is on the list below when it is not a public address and both or user data.** A metadata address is on the list below when it is not
of these hold: the provider fixes it, so it cannot collide with a public address and both of these hold: the provider fixes it, so it
anything you run; and reaching it hands out credentials, user data or cannot collide with anything you run; and reaching it hands out
bootstrap material. Those stay blocked no matter what you list, credentials, user data or bootstrap material. Those stay blocked no
including when you list them outright or list a supernet such as matter what you list, including when you list them outright or list a
`0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`. Treat this as best supernet such as `0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`.
effort rather than a guarantee — it is a hand-maintained list and the Treat this as best effort rather than a guarantee — it is a
caveat below the table applies: hand-maintained list and the caveat below the table applies:
| Blocked unconditionally | What it is | | Blocked unconditionally | What it is |
| ----------------------- | ---------- | | ----------------------- | ---------- |
@@ -233,14 +239,25 @@ Two things this setting cannot do:
| `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 | | `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 |
| `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT | | `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT |
| `192.0.0.192/32` | Oracle Cloud Classic metadata | | `192.0.0.192/32` | Oracle Cloud Classic metadata |
| `0.0.0.0/32` | IPv4 unspecified address, which reaches this host's loopback on Linux |
| `::/128` | IPv6 unspecified address, which reaches this host's loopback on Linux |
| `::a9fe:a9fe/128` | `169.254.169.254` as an IPv4-compatible IPv6 address | | `::a9fe:a9fe/128` | `169.254.169.254` as an IPv4-compatible IPv6 address |
| `64:ff9b::a9fe:a9fe/128` | `169.254.169.254` behind the NAT64 well-known prefix | | `64:ff9b::a9fe:a9fe/128` | `169.254.169.254` behind the NAT64 well-known prefix |
The IPv4-mapped form `::ffff:169.254.169.254` is covered by the The IPv4-mapped form `::ffff:169.254.169.254` is covered by the
`169.254.0.0/16` entry. Reaching any of these is credential or `169.254.0.0/16` entry. Reaching any of these but the two unspecified
user-data theft rather than delivery to an internal service. Every addresses is credential or user-data theft rather than delivery to an
entry outside the two link-local blocks is a single address, so internal service. Every entry outside the two link-local blocks is a
blocking it costs you nothing else on the network around it. single address, so blocking it costs you nothing else on the network
around it.
The unspecified addresses `0.0.0.0` and `::` hand out nothing
themselves, but no host can have either, and on Linux a connection to
one reaches this host's own loopback. They are listed so that an
allowlist reaches loopback only through an entry that covers a loopback
address, such as `127.0.0.0/8`, `::1` or `0.0.0.0/0`, never through one
that covers only `0.0.0.0` or `::`; `0.0.0.0/8`, for example, does not
open loopback.
The six ULA entries, all inside `fd00::/8`, are why this matters in The six ULA entries, all inside `fd00::/8`, are why this matters in
practice: `fd00::/8` is an ordinary block to allowlist for your own practice: `fd00::/8` is an ordinary block to allowlist for your own
@@ -440,6 +457,19 @@ Your proxy must therefore **append** the peer address to
`option forwardfor`, Caddy and AWS ALB by default), and must append a `option forwardfor`, Caddy and AWS ALB by default), and must append a
bare address with no port. bare address with no port.
Every log line that names a client carries two addresses: `remoteIP`,
the connecting peer, which behind a proxy is the proxy; and `clientIP`,
the client the rate limiters identify by the rules above, which is the
field to read when tracing who sent what. Those lines are the
`http request` access log line, the rate-limit rejection lines
(`login failure limit exceeded` among them), the
`csrf: token validation failed` warning and the receiver's
`webhook request received` line. `clientIP` is only as trustworthy as
`TRUSTED_PROXIES`: for a request from a peer inside the list, it is
read out of the `X-Forwarded-For` that peer sent, so a peer that does
not belong in the list can make it name any address it likes. For a
request from any other peer, both fields name the peer.
#### Sessions #### Sessions
Sessions are bounded by two independent clocks, and end at whichever Sessions are bounded by two independent clocks, and end at whichever
@@ -498,8 +528,8 @@ no report is being sent — which is why it aborts rather than starting
with reporting off. Leaving it unset is not a mistake and not affected: with reporting off. Leaving it unset is not a mistake and not affected:
error reporting is simply off and startup is normal. error reporting is simply off and startup is normal.
Boolean variables (`DEBUG`, `MAINTENANCE_MODE`) accept exactly the The boolean variable `DEBUG` accepts exactly the spellings Go's
spellings Go's `strconv.ParseBool` accepts — `1`, `t`, `T`, `TRUE`, `strconv.ParseBool` accepts — `1`, `t`, `T`, `TRUE`,
`true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, `False` — and nothing `true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, `False` — and nothing
else. `yes`, `on`, and `off` are rejected rather than quietly treated else. `yes`, `on`, and `off` are rejected rather than quietly treated
as false. as false.
@@ -527,8 +557,8 @@ If it is lost, run `webhooker resetpw admin` on a stopped deployment.
``` ```
It is a banner rather than a log line because that is the only time it It is a banner rather than a log line because that is the only time it
is ever shown: as one `INFO` record it sat among the roughly 45 fx is ever shown: as one `INFO` record it would sit among the records fx
`PROVIDE`/`RUN`/`HOOK` lines a boot writes, and under `docker run -d` writes as each start hook runs, and under `docker run -d`
it is one line in a log subject to rotation. The database stores only it is one line in a log subject to rotation. The database stores only
its Argon2id hash. There is no second account and no forgot-password its Argon2id hash. There is no second account and no forgot-password
flow, so the banner and the reset command below are the only two ways flow, so the banner and the reset command below are the only two ways
@@ -598,7 +628,8 @@ Changing a password you still know needs none of this — use
`DEBUG=true` lowers the log level to `DEBUG`, which turns on every `DEBUG=true` lowers the log level to `DEBUG`, which turns on every
statement GORM runs, the two by-design lookup misses on the statement GORM runs, the two by-design lookup misses on the
unauthenticated routes, and the rate limiter's own rejections. It is unauthenticated routes, the rate limiter's own rejections, and fx's
records of building the dependency graph at startup. It is
meant to be safe to turn on while diagnosing a live service and safe to meant to be safe to turn on while diagnosing a live service and safe to
paste the output of into a bug report. paste the output of into a bug report.
@@ -824,9 +855,9 @@ reports.
was given, so on any port other than 443 `$host` makes every form was given, so on any port other than 443 `$host` makes every form
POST — including login — fail with `403 origin invalid`, with POST — including login — fail with `403 origin invalid`, with
nothing in the error naming the cause. nothing in the error naming the cause.
5. **Keep the proxy's access log.** webhooker's own access log records 5. **Keep the proxy's access log.** webhooker's own access log names
the peer address, which behind a proxy is always the proxy. The the client in its `clientIP` field only while `TRUSTED_PROXIES`
proxy's log is the only record of which client sent what. nginx's covers the proxy; the proxy's log names it regardless. nginx's
default `combined` format already logs `$remote_addr`; do not default `combined` format already logs `$remote_addr`; do not
replace it with one that drops the client address, and retain those replace it with one that drops the client address, and retain those
logs as long as you would want to answer a question about traffic. logs as long as you would want to answer a question about traffic.
@@ -855,9 +886,8 @@ server {
# webhooker's message. # webhooker's message.
client_max_body_size 1m; client_max_body_size 1m;
# $remote_addr is the client. webhooker's own log records this # $remote_addr is the client. webhooker's own log names it, as
# proxy and nothing else, so this file is the only place the # clientIP, only while TRUSTED_PROXIES covers this proxy.
# client's address is written down.
access_log /var/log/nginx/webhooker.access.log combined; access_log /var/log/nginx/webhooker.access.log combined;
location / { location / {
@@ -1241,7 +1271,7 @@ What that means for an operator:
This repository adheres to the This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the standard: normalized scripts in `script/` are the entrypoints for the
development workflow. Ten of the Makefile's seventeen targets are thin development workflow. Eleven of the Makefile's eighteen targets are thin
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
`version` are inline commands with no script behind them, though `build`, `version` are inline commands with no script behind them, though `build`,
`run` and `dev` first run `script/assets`, and `build` and `version` both `run` and `dev` first run `script/assets`, and `build` and `version` both
@@ -1262,6 +1292,8 @@ We provide:
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see - `script/assets` — extract Alpine.js from its tarball in `3p/` (see
[Third-party browser assets](#third-party-browser-assets)) [Third-party browser assets](#third-party-browser-assets))
- `script/test` — run the test suite - `script/test` — run the test suite
- `script/test-browser` — run the browser test in Docker (see
[Third-party browser assets](#third-party-browser-assets))
- `script/lint` — run golangci-lint in Docker (see Linting below) - `script/lint` — run golangci-lint in Docker (see Linting below)
- `script/fmt` — format all code (writes) - `script/fmt` — format all code (writes)
- `script/fmt-check` — check formatting (read-only) - `script/fmt-check` — check formatting (read-only)
@@ -1282,11 +1314,32 @@ We provide:
## Third-party browser assets ## Third-party browser assets
The web UI serves one third-party script, Alpine.js. Its npm package tarball The web UI serves one third-party script, Alpine.js, in its CSP build: the npm
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry package `@alpinejs/csp`. The pages' Content-Security-Policy forbids eval, which
publishes it. It is a dependency, not this repo's build output, so the standard `alpinejs` build needs to run the expressions written in the
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply. markup. The CSP build runs no expressions, so every Alpine directive in
The directory is `3p/` rather than `vendor/` because Go treats a root `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. `make check`
and the image build lint it but do not run it, and `make test` leaves it out
(its file is built only with the `browser` build tag). Run it with
`make test-browser` after changing `templates/` or `static/js/`: that builds
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser
image, so the host needs no browser.
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
byte as the npm registry publishes it. It is a dependency, not this repo's build
output, so `REPO_POLICIES.md`'s rule against committed build artifacts does not
apply. 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.
`script/assets` (`make assets`) extracts the browser build, `script/assets` (`make assets`) extracts the browser build,
@@ -1297,10 +1350,11 @@ nothing downloads Alpine.js. The extracted file is not committed, and
`.dockerignore` keeps any host copy out of the build context. `.dockerignore` keeps any host copy out of the build context.
To move to a new version: download To move to a new version: download
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it `https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it against
against the `dist.integrity` hash listed at the `dist.integrity` hash listed at
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/` `https://registry.npmjs.org/@alpinejs/csp/<version>`, replace the tarball in
with it, update its file name in `script/assets`, and run `make check`. `3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
`script/assets`, and run `make check` and `make test-browser`.
## Rationale ## Rationale
@@ -1770,7 +1824,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 |
| ---------------- | --------- | ----------- | | ---------------- | --------- | ----------- |
@@ -1802,6 +1856,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
@@ -1809,7 +1871,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 |
@@ -1919,8 +1981,10 @@ when nothing is left. The target UUID keeps the file name unique. A
webhook named `Orders (EU)` with a target named `Long-term archive` webhook named `Orders (EU)` with a target named `Long-term archive`
archives into `archive-orders-eu-long-term-archive-{target_uuid}.db`. archives into `archive-orders-eu-long-term-archive-{target_uuid}.db`.
Renaming the webhook or the target renames the file, under the same Renaming the webhook or the target renames the file, under the same
lock the archive writes and the archive sweeper take, so the name on lock the archive writes and the archive sweeper take. Webhook edits,
disk matches the UI. A rename never replaces a file: if one already has target edits and target creation run one at a time, so no edit can
rename the file between another's rename and save, and the name on disk
matches the UI. A rename never replaces a file: if one already has
the new name, the edit is refused with an error naming that file, and the new name, the edit is refused with an error naming that file, and
the stored name stays. If the archive is not there (the operator moved the stored name stays. If the archive is not there (the operator moved
it away), the rename is not an error, and the next write creates the it away), the rename is not an error, and the next write creates the
@@ -1929,10 +1993,14 @@ file under the new name.
The file is moved just before the new name is saved. If the process The file is moved just before the new name is saved. If the process
stops between the two, the archive is left under the new name while the stops between the two, the archive is left under the new name while the
UI still shows the old one, and the next delivery starts a second UI still shows the old one, and the next delivery starts a second
archive under the name shown. To bring them back together, move the archive under the name shown. To bring them back together, stop the
file under the new name back to the name shown; if a second archive is service before moving anything, and move each archive as its `.db`
already there, move the older file out of the data directory instead together with any `-wal` and `-shm` beside it, since the `-wal` can hold
and keep it as you would any archive moved away. rows that are not yet in the `.db`. If no file has the name shown, move
the archive under the new name back to it. If a second archive already
has the name shown, move the archive under the new name out of the data
directory instead and keep it as you would any archive moved away. Then
start the service again.
After each write the archive handle is closed After each write the archive handle is closed
and reopened, debounced to at most once per second, so an operator can and reopened, debounced to at most once per second, so an operator can
@@ -2418,20 +2486,21 @@ trade.
Net: **one `INFO` line per request, of at most 2,560 bytes.** That Net: **one `INFO` line per request, of at most 2,560 bytes.** That
ceiling is arithmetic, not an observation: 3 × (512 + 11) for `url`, ceiling is arithmetic, not an observation: 3 × (512 + 11) for `url`,
`useragent` and `referer`, plus 128 + 11 for `request_id`, plus 32 + 11 `useragent` and `referer`, plus 128 + 11 for `request_id`, plus 32 + 11
for `method`, plus a 336-byte fixed portion (the field names, the for `method`, plus a 405-byte fixed portion (the field names, the
punctuation, both timestamps at their longest, an IPv6 `remoteIP` with punctuation, both timestamps at their longest, `remoteIP` and
a zone, the status and the latency) — 2,087 bytes, stated at 2,560 so `clientIP` each charged as an IPv6 address with a zone, the status and
the figure has headroom. `internal/middleware/accesslog_test.go` the latency) — 2,156 bytes, stated at 2,560 so the figure has headroom.
asserts it against 8 KB of client-chosen text in the path, in the `internal/middleware/accesslog_test.go` asserts it against 8 KB of
query, and in each of `User-Agent`, `Referer` and `X-Request-Id`, client-chosen text in the path, in the query, and in each of
`User-Agent`, `Referer`, `X-Request-Id` and `X-Forwarded-For`,
including cases built from the characters the handlers escape, and including cases built from the characters the handlers escape, and
against the widest access log line the service can be made to write: a against a 5xx that keeps its concrete path while all three header fields
5xx that keeps its concrete path while all three header fields are also are also at their budget and an `X-Forwarded-For` sent from a trusted
at their budget. Every case runs through both handlers proxy ends in an IPv6 client address at its longest followed by an 8 KB
`internal/logger` can select — the JSON one and the text one it installs zone, where `clientIP` must name the address without the zone. Every
on a tty — since the two do not escape alike and the ceiling is quoted case runs through both handlers `internal/logger` can select — the JSON
unqualified. Measured over a real connection, the widest access log line one and the text one it installs on a tty — since the two do not escape
is 1,972 bytes. alike and the ceiling is quoted unqualified.
Multiply that ceiling by the request rate to size log storage. Note Multiply that ceiling by the request rate to size log storage. Note
that the rate is not bounded by the limits above on every route: that the rate is not bounded by the limits above on every route:
@@ -2569,16 +2638,20 @@ read as more than it is:
that type on a specific webhook, and each line it writes is bounded that type on a specific webhook, and each line it writes is bounded
per event by the 1 MB receiver body cap. Adding one is a decision to per event by the 1 MB receiver body cap. Adding one is a decision to
spend log volume on that webhook's payloads. spend log volume on that webhook's payloads.
- **Two writers that do not go through `internal/logger` at all**, both - **The Go runtime**, which does not go through `internal/logger`. The
on standard error. `fx` prints the dependency graph and the lifecycle runtime writes an unrecovered panic or a fatal error itself, as plain
hooks through its default console logger at startup and shutdown — text on standard error, and that output cannot be redirected. A panic
nothing calls `fx.WithLogger`, and `fx.New` builds that logger over in a background worker rather than in a request handler is the case
`os.Stderr`. The Go runtime writes a panic or a fatal error itself; a that reaches it, since nothing recovers those. It carries no
panic in a background worker rather than in a request handler is the client-chosen value at a client-chosen length: the service's own
case that reaches it, since nothing recovers those. Neither carries a `panic` calls are invariant guards over constants and over
client-chosen value at a client-chosen length: the five `panic` calls `crypto/rand`, apart from the one that hands `http.ErrAbortHandler`
in this service are invariant guards over constants and over back to `net/http`, described below.
`crypto/rand`. - **A failure before fx's logger is built**, such as an invalid
configuration value. fx's logger takes the configuration, so when
that fails fx's own console logger still prints the failure as plain
text on standard error. Its values come from the operator's
environment, not from a client.
- **`net/http`'s own faults**, which are _not_ a separate writer. - **`net/http`'s own faults**, which are _not_ a separate writer.
`internal/server/http.go` builds its server with a nil `ErrorLog`, so `internal/server/http.go` builds its server with a nil `ErrorLog`, so
`net/http` falls back to the `log` package's default logger — and `net/http` falls back to the `log` package's default logger — and
@@ -2769,9 +2842,9 @@ remedies are to block the source at the reverse proxy, or to
rate-limit `POST /pages/login` there — the one place a limit can be rate-limit `POST /pages/login` there — the one place a limit can be
applied without reintroducing the lockout, because the proxy sees the applied without reintroducing the lockout, because the proxy sees the
real client address. `TRUSTED_PROXIES` does not stop the saturation. real client address. `TRUSTED_PROXIES` does not stop the saturation.
The flood's source is in the proxy's access log: webhooker's own logs The flood's source is in the `clientIP` field of webhooker's access
record the proxy's address, not the client's (see log while `TRUSTED_PROXIES` covers the proxy, and in the proxy's own
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)). access log either way (see [Trusted proxies](#trusted-proxies)).
Finer-grained per-webhook rate limits (configured in the web UI and Finer-grained per-webhook rate limits (configured in the web UI and
enforced in the webhook handler) can layer on top of this env-level enforced in the webhook handler) can layer on top of this env-level
@@ -2784,7 +2857,7 @@ abuse limit later; they are tracked as future work.
| Method | Path | Description | | Method | Path | Description |
| ------ | --------------------------- | ----------- | | ------ | --------------------------- | ----------- |
| `GET` | `/` | Root redirect, 303 (authenticated → `/hooks`, unauthenticated → `/pages/login`) | | `GET` | `/` | Root redirect, 303 (authenticated → `/hooks`, unauthenticated → `/pages/login`) |
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) | | `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`) |
| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` | | `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` |
| `POST` | `/h/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) | | `POST` | `/h/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) |
@@ -2806,6 +2879,7 @@ returns to the page that was asked for.
| ------ | ------------------------ | ----------- | | ------ | ------------------------ | ----------- |
| `GET` | `/user/{username}` | User profile page | | `GET` | `/user/{username}` | User profile page |
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) | | `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
| `GET` | `/settings` | Read-only list of the configuration the server is running with; `METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set |
| `GET` | `/hooks` | List user's webhooks | | `GET` | `/hooks` | List user's webhooks |
| `GET` | `/hooks/new` | Create webhook form | | `GET` | `/hooks/new` | Create webhook form |
| `POST` | `/hooks/new` | Create webhook submission | | `POST` | `/hooks/new` | Create webhook submission |
@@ -2857,7 +2931,7 @@ imports. The entry point is `cmd/webhooker/main.go`.
``` ```
webhooker/ webhooker/
├── 3p/ ├── 3p/
│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets │ └── alpinejs-csp-3.14.9.tgz # Alpine.js CSP build npm package, extracted by make assets
├── cmd/webhooker/ ├── cmd/webhooker/
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx │ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
├── internal/ ├── internal/
@@ -2919,6 +2993,7 @@ webhooker/
│ │ ├── healthcheck.go # Health check handler │ │ ├── healthcheck.go # Health check handler
│ │ ├── index.go # Index page handler │ │ ├── index.go # Index page handler
│ │ ├── profile.go # User profile handler │ │ ├── profile.go # User profile handler
│ │ ├── settings.go # Read-only Settings page handler
│ │ ├── source_management.go # Webhook CRUD handlers │ │ ├── source_management.go # Webhook CRUD handlers
│ │ └── webhook.go # Webhook receiver handler │ │ └── webhook.go # Webhook receiver handler
│ ├── healthcheck/ │ ├── healthcheck/
@@ -2926,7 +3001,7 @@ webhooker/
│ ├── lifecycle/ │ ├── lifecycle/
│ │ └── lifecycle.go # Shared stop-hook waiter, bounded by the stop context │ │ └── lifecycle.go # Shared stop-hook waiter, bounded by the stop context
│ ├── logger/ │ ├── logger/
│ │ └── logger.go # slog setup with TTY detection │ │ └── logger.go # slog setup with TTY detection; fx's event logger
│ ├── metrics/ │ ├── metrics/
│ │ └── metrics.go # Delivery Prometheus collectors, labelled by target type │ │ └── metrics.go # Delivery Prometheus collectors, labelled by target type
│ ├── middleware/ │ ├── middleware/
@@ -2951,13 +3026,14 @@ webhooker/
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css) │ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
│ ├── css/tailwind.css # Generated stylesheet the pages load │ ├── css/tailwind.css # Generated stylesheet the pages load
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded │ ├── css/style.css # Older hand-written stylesheet, no longer loaded
│ ├── js/app.js # Progressive-enhancement copy-to-clipboard │ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed │ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
├── templates/ # Go HTML templates (base, login, sources, etc.) ├── templates/ # Go HTML templates (base, login, sources, etc.)
├── script/ # Scripts to Rule Them All entrypoints ├── script/ # Scripts to Rule Them All entrypoints
├── Dockerfile # Three stages: lint, test+build, Alpine runtime ├── Dockerfile # Three stages: lint, test+build, Alpine runtime
├── Dockerfile.lint # Lint-only image built by script/lint ├── Dockerfile.lint # Lint-only image built by script/lint
├── Makefile # 10 of 17 targets shim script/; 7 are inline ├── Dockerfile.browser # Browser test image built by script/test-browser
├── Makefile # 11 of 18 targets shim script/; 7 are inline
├── go.mod / go.sum ├── go.mod / go.sum
└── .golangci.yml # Linter configuration └── .golangci.yml # Linter configuration
``` ```
@@ -2977,13 +3053,15 @@ Components are wired via Uber fx in this order:
7. `healthcheck.New` — Health check service 7. `healthcheck.New` — Health check service
8. `session.New` — Cookie-based session manager (key from database) 8. `session.New` — Cookie-based session manager (key from database)
9. `handlers.New` — HTTP handlers 9. `handlers.New` — HTTP handlers
10. `middleware.New` — HTTP middleware 10. `metrics.NewRegistry` — The registry `/metrics` serves
11. `delivery.New` — Event-driven delivery engine 11. `metrics.New` — The delivery collectors, registered on that registry
12. `delivery.NewArchiveSweeper` — Periodic pruning of idle archives 12. `middleware.New` — HTTP middleware
13. `delivery.Engine` → `delivery.Notifier` — interface bridge 13. `delivery.New` — Event-driven delivery engine
14. `delivery.Engine` → `delivery.Archives` — interface bridge so 14. `delivery.NewArchiveSweeper` — Periodic pruning of idle archives
15. `delivery.Engine` → `delivery.Notifier` — interface bridge
16. `delivery.Engine` → `delivery.Archives` — interface bridge so
deleting or renaming a webhook or target reaches its archive files deleting or renaming a webhook or target reaches its archive files
15. `server.New` — HTTP server and router 17. `server.New` — HTTP server and router
The server starts via `fx.Invoke(func(*server.Server, *delivery.Engine, The server starts via `fx.Invoke(func(*server.Server, *delivery.Engine,
*database.RetentionReaper, *delivery.ArchiveSweeper) {})`, which *database.RetentionReaper, *delivery.ArchiveSweeper) {})`, which
@@ -3004,7 +3082,7 @@ Applied to all routes in this order:
(HSTS, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, (HSTS, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy,
Permissions-Policy) Permissions-Policy)
3. **Logging** — Structured request logging (method, URL, status, 3. **Logging** — Structured request logging (method, URL, status,
latency, remote IP, user agent, request ID) latency, remote IP, client IP, user agent, request ID)
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and 4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
`METRICS_PASSWORD` are both set) `METRICS_PASSWORD` are both set)
5. **CORS** — Cross-origin resource sharing headers 5. **CORS** — Cross-origin resource sharing headers
@@ -3026,14 +3104,14 @@ local record instead of nothing. What that placement gives up is
recovery of a panic in the six entries above it, none of which does recovery of a panic in the six entries above it, none of which does
more than set a header or start a timer. more than set a header or start a timer.
Each admin page route group (`/pages`, `/user/*`, `/hooks`, Each admin page route group (`/pages`, `/user/*`, `/settings`, `/hooks`,
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is `/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is set, its
set, its own **Sentry** error reporting. That Recoverer answers a panic own **Sentry** error reporting. That Recoverer answers a panic with the `500`
with the `500` error page in the normal layout; the global one keeps error page in the normal layout; the global one keeps the plain-text `500` for
the plain-text `500` for every other route. every other route.
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`, Additionally, form endpoints (`/pages`, `/user/*`, `/settings`,
`/hook/*`) apply a **MaxBodySize** middleware that limits `/hooks`, `/hook/*`) apply a **MaxBodySize** middleware that limits
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
CSRF middleware in every one of those route groups, because CSRF middleware in every one of those route groups, because
gorilla/csrf parses the form; if the cap were installed after it, form gorilla/csrf parses the form; if the cap were installed after it, form
@@ -3052,7 +3130,7 @@ declared length. A chunked request, or
one that lies about its length, is hard-capped by one that lies about its length, is hard-capped by
`http.MaxBytesReader` and fails downstream at form-parse time. `http.MaxBytesReader` and fails downstream at form-parse time.
Those same four route groups then apply **CSRF** and **NoCache** Those same five route groups then apply **CSRF** and **NoCache**
(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except (`Cache-Control: no-store`, `Pragma: no-cache`), and every group except
`/pages` applies **RequireAuth**. The rate limiters are per-route `/pages` applies **RequireAuth**. The rate limiters are per-route
rather than global: **PasswordChangeRateLimit** on rather than global: **PasswordChangeRateLimit** on
@@ -3098,12 +3176,12 @@ check, see [The login endpoint](#the-login-endpoint).
by middleware that runs before CSRF parses the form by middleware that runs before CSRF parses the form
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) - **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
on all state-changing forms (cookie-based double-submit tokens with on all state-changing forms (cookie-based double-submit tokens with
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`, and HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`,
`/user` routes. Excluded from `/h` (inbound webhook POSTs) and `/settings`, and `/user` routes. Excluded from `/h` (inbound webhook
`/api` (stateless API). The middleware detects TLS per-request through POSTs) and `/api` (stateless API). The middleware detects TLS
`internal/reqtls.IsTLS` — the same predicate the session cookie uses — per-request through `internal/reqtls.IsTLS` — the same predicate the
to set appropriate cookie security flags and Origin/Referer validation session cookie uses — to set appropriate cookie security flags and
mode Origin/Referer validation mode
- **The entrypoint URL is the receiver's only credential.** Nothing - **The entrypoint URL is the receiver's only credential.** Nothing
about an inbound request is verified; possession of the UUID about an inbound request is verified; possession of the UUID
authorises submission, and no shared secret or signature check will authorises submission, and no shared secret or signature check will
@@ -3117,7 +3195,8 @@ check, see [The login endpoint](#the-login-endpoint).
route through a single decision function, so they cannot disagree route through a single decision function, so they cannot disagree
about a destination. An operator can permit specific blocks with about a destination. An operator can permit specific blocks with
[`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the [`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the
guard cannot be switched off, and link-local plus a guard cannot be switched off, and link-local, the unspecified
addresses `0.0.0.0` and `::`, and a
[pinned set](#allowing-egress-to-your-own-network) of known cloud [pinned set](#allowing-egress-to-your-own-network) of known cloud
metadata endpoints — several of which are ULAs outside link-local — metadata endpoints — several of which are ULAs outside link-local —
stay blocked whatever is listed, though listing `0.0.0.0/0` or stay blocked whatever is listed, though listing `0.0.0.0/0` or
@@ -3300,8 +3379,9 @@ linked, which is what lets it run on the Alpine runtime image.
inside the image, so a build that succeeds is a repo that is formatted, inside the image, so a build that succeeds is a repo that is formatted,
linted, tested and compiled. `script/lint` also uses Docker linted, tested and compiled. `script/lint` also uses Docker
(`Dockerfile.lint`, see Linting above), so `make lint` and `make check` (`Dockerfile.lint`, see Linting above), so `make lint` and `make check`
run the same pinned linter version the gate does; only `script/test` run the same pinned linter version the gate does; of the steps
and `script/fmt-check` run on the host. `make check` runs, only `script/test` and `script/fmt-check` run on the
host.
#### CI gate honesty #### CI gate honesty
-6
View File
@@ -40,12 +40,6 @@ duplicate. That is deliberate — the alternative is a silent lost
delivery — and the README says so under Rationale. It is not a defect delivery — and the README says so under Rationale. It is not a defect
to re-file. to re-file.
One caveat on reading a green check: a docs-only commit deliberately
replays from the layer cache
(https://git.eeqj.de/sneak/webhooker/issues/119), so a green status on
such a commit evidences a replay rather than an executed run. A code
commit invalidates the `COPY` layer and genuinely executes.
# Next Step # Next Step
Clear the rest of the open 1.0.0 milestone Clear the rest of the open 1.0.0 milestone
+19
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxevent"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/datadir"
@@ -16,6 +17,7 @@ import (
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/resetpw" "sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/server" "sneak.berlin/go/webhooker/internal/server"
@@ -167,6 +169,19 @@ func run(stderr io.Writer) int {
func newApp() *fx.App { func newApp() *fx.App {
return fx.New( return fx.New(
fx.StopTimeout(stopTimeout), fx.StopTimeout(stopTimeout),
// fx's own events go through the service's logger, not fx's
// console logger on standard error. The exception is a failure
// before this logger is built, such as an invalid configuration
// value, which fx's console logger still prints there. fx holds
// its events back until this logger is built and then replays
// them, so it takes the configuration, which sets the level
// DEBUG=true asks for: without it the replay would run at INFO
// and drop every record of how the graph was built.
fx.WithLogger(
func(l *logger.Logger, _ *config.Config) fxevent.Logger {
return logger.NewFxLogger(l.Get())
},
),
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -177,6 +192,10 @@ func newApp() *fx.App {
healthcheck.New, healthcheck.New,
session.New, session.New,
handlers.New, handlers.New,
// The registry /metrics serves, and the delivery
// collectors registered on it.
metrics.NewRegistry,
metrics.New,
middleware.New, middleware.New,
// The one SSRF guard both target-creation validation // The one SSRF guard both target-creation validation
// and the delivery dialer consult, so they cannot // and the delivery dialer consult, so they cannot
+99
View File
@@ -2,6 +2,12 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json"
"io"
"log/slog"
"net"
"os"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -38,6 +44,99 @@ func TestNewApp_StopTimeout(t *testing.T) {
require.Less(t, got, dockerStopGrace) require.Less(t, got, dockerStopGrace)
} }
// freePort returns a loopback TCP port that was free a moment ago, by
// taking one and releasing it.
func freePort(t *testing.T) int {
t.Helper()
var listenCfg net.ListenConfig
l, err := listenCfg.Listen(t.Context(), "tcp", "127.0.0.1:0")
require.NoError(t, err)
addr, ok := l.Addr().(*net.TCPAddr)
require.True(t, ok, "listener is not TCP")
require.NoError(t, l.Close())
return addr.Port
}
// TestNewApp_SendsFxEventsToTheLogger starts and stops the app main
// runs, with DEBUG=true, and reads back what reached the service's
// logger. fx's own events must arrive there as structured records:
// the start at INFO, and at DEBUG the records of how the graph was
// built.
//
// fx holds its events back until its logger is built and then replays
// them all at once, so the earliest of them arriving shows the replay
// ran at DEBUG: that globals.New was provided, which fx records before
// anything is built, and the run of logger.New, which happens before
// the configuration sets the level.
func TestNewApp_SendsFxEventsToTheLogger(t *testing.T) {
t.Setenv("DATA_DIR", t.TempDir())
t.Setenv("PORT", strconv.Itoa(freePort(t)))
t.Setenv("DEBUG", "true")
// internal/logger writes to whatever os.Stdout is when it builds
// its handler. A file is not a terminal, so that handler is the
// JSON one the service uses in production.
out, err := os.CreateTemp(t.TempDir(), "stdout")
require.NoError(t, err)
stdout := os.Stdout
os.Stdout = out
t.Cleanup(func() {
os.Stdout = stdout
_ = out.Close()
})
app := newApp()
require.NoError(t, app.Start(t.Context()))
require.NoError(t, app.Stop(t.Context()))
_, err = out.Seek(0, io.SeekStart)
require.NoError(t, err)
written, err := io.ReadAll(out)
require.NoError(t, err)
type record struct {
Level string `json:"level"`
Msg string `json:"msg"`
Name string `json:"name"`
Constructor string `json:"constructor"`
}
var records []record
for line := range strings.Lines(string(written)) {
var r record
// The first-boot banner is plain text, not a record.
if json.Unmarshal([]byte(line), &r) == nil {
records = append(records, r)
}
}
const pkg = "sneak.berlin/go/webhooker/internal/"
info := slog.LevelInfo.String()
debug := slog.LevelDebug.String()
assert.Contains(t, records, record{Level: info, Msg: "started"})
assert.Contains(t, records, record{
Level: debug, Msg: "provided", Constructor: pkg + "globals.New()",
})
assert.Contains(t, records, record{
Level: debug, Msg: "run", Name: pkg + "logger.New()",
})
assert.Contains(t, records, record{Level: debug, Msg: "invoking"})
assert.Contains(t, records, record{
Level: debug, Msg: "initialized custom fxevent.Logger",
})
}
// TestRunRefusesLockedDataDir pins what an operator's second start // TestRunRefusesLockedDataDir pins what an operator's second start
// does. The entry point must refuse before it builds the fx graph — // does. The entry point must refuse before it builds the fx graph —
// nothing may open a database in a DATA_DIR another process holds — // nothing may open a database in a DATA_DIR another process holds —
+12 -7
View File
@@ -4,6 +4,8 @@ go 1.26.1
require ( require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f
github.com/chromedp/chromedp v0.16.0
github.com/dustin/go-humanize v1.0.1 github.com/dustin/go-humanize v1.0.1
github.com/getsentry/sentry-go v0.25.0 github.com/getsentry/sentry-go v0.25.0
github.com/go-chi/chi v1.5.5 github.com/go-chi/chi v1.5.5
@@ -18,7 +20,7 @@ require (
github.com/prometheus/client_model v0.5.0 github.com/prometheus/client_model v0.5.0
github.com/slok/go-http-metrics v0.11.0 github.com/slok/go-http-metrics v0.11.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
go.uber.org/fx v1.20.1 go.uber.org/fx v1.24.0
golang.org/x/crypto v0.38.0 golang.org/x/crypto v0.38.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.5.4 gorm.io/driver/sqlite v1.5.4
@@ -29,13 +31,17 @@ 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
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
@@ -44,13 +50,12 @@ require (
github.com/prometheus/procfs v0.12.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/atomic v1.9.0 // indirect go.uber.org/dig v1.19.0 // indirect
go.uber.org/dig v1.17.0 // indirect go.uber.org/multierr v1.10.0 // indirect
go.uber.org/multierr v1.9.0 // indirect go.uber.org/zap v1.26.0 // indirect
go.uber.org/zap v1.23.0 // indirect
golang.org/x/mod v0.17.0 // indirect golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.14.0 // indirect golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.25.0 // indirect golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.31.0 // indirect google.golang.org/protobuf v1.31.0 // indirect
+30 -22
View File
@@ -1,14 +1,15 @@
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go= github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs= github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -23,6 +24,14 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
@@ -55,17 +64,20 @@ 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=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
@@ -82,28 +94,24 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0= github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0=
github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc= github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI= go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
go.uber.org/dig v1.17.0/go.mod h1:rTxpf7l5I0eBTlE6/9RL+lDybC7WFwY2QH55ZSjy1mU= go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
go.uber.org/fx v1.20.1 h1:zVwVQGS8zYvhh9Xxcu4w1M6ESyeMzebzj2NbSayZ4Mk= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/fx v1.20.1/go.mod h1:iSYNbHf2y55acNCwCXKx7LbWb5WG1Bnue5RDXz1OREg= go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
@@ -111,8 +119,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
+7 -14
View File
@@ -149,7 +149,6 @@ type ConfigParams struct {
type Config struct { type Config struct {
DataDir string DataDir string
Debug bool Debug bool
MaintenanceMode bool
Environment string Environment string
MetricsPassword string MetricsPassword string
MetricsUsername string MetricsUsername string
@@ -196,12 +195,13 @@ type Config struct {
// otherwise refuse. The guard itself is always on: there is no // otherwise refuse. The guard itself is always on: there is no
// setting that disables SSRF protection, and delivery's // setting that disables SSRF protection, and delivery's
// alwaysBlockedNetworks stays blocked no matter what is listed // alwaysBlockedNetworks stays blocked no matter what is listed
// here. That set is link-local plus the cloud metadata // here. That set is link-local, the unspecified addresses
// endpoints outside it that disclose credentials or user data // 0.0.0.0 and ::, and the cloud metadata endpoints outside
// at a provider-fixed, non-public address; it is not // link-local that disclose credentials or user data at a
// exhaustive of every cloud's metadata address. See // provider-fixed, non-public address; it is not exhaustive of
// alwaysBlockedNetworks for the authoritative list and the // every cloud's metadata address. See
// criterion it is built from. // alwaysBlockedNetworks for the authoritative list and why
// each entry is on it.
AllowedEgressCIDRs []netip.Prefix AllowedEgressCIDRs []netip.Prefix
params *ConfigParams params *ConfigParams
@@ -657,11 +657,6 @@ func loadFromEnv() (*Config, error) {
return nil, err return nil, err
} }
maintenanceMode, err := envBool("MAINTENANCE_MODE", false)
if err != nil {
return nil, err
}
retentionSweepInterval, err := envPositiveDuration( retentionSweepInterval, err := envPositiveDuration(
"RETENTION_SWEEP_INTERVAL", "RETENTION_SWEEP_INTERVAL",
defaultRetentionSweepInterval, defaultRetentionSweepInterval,
@@ -711,7 +706,6 @@ func loadFromEnv() (*Config, error) {
return &Config{ return &Config{
DataDir: DataDir(), DataDir: DataDir(),
Debug: debug, Debug: debug,
MaintenanceMode: maintenanceMode,
Environment: environment, Environment: environment,
MetricsUsername: metricsUsername, MetricsUsername: metricsUsername,
MetricsPassword: metricsPassword, MetricsPassword: metricsPassword,
@@ -798,7 +792,6 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
// host can reach the admin UI. // host can reach the admin UI.
"bindAddress", s.BindAddress, "bindAddress", s.BindAddress,
"debug", s.Debug, "debug", s.Debug,
"maintenanceMode", s.MaintenanceMode,
"dataDir", s.DataDir, "dataDir", s.DataDir,
"retentionSweepInterval", s.RetentionSweepInterval.String(), "retentionSweepInterval", s.RetentionSweepInterval.String(),
// Logged because a perfectly valid non-positive value here // Logged because a perfectly valid non-positive value here
+12
View File
@@ -124,6 +124,11 @@ func testEnvironmentConfigSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned. The same holds for every fxtest.New
// below.
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -272,6 +277,7 @@ func testRetentionSweepIntervalSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -364,6 +370,7 @@ func testSessionIdleTimeoutSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -404,6 +411,7 @@ func TestDefaultDataDir(t *testing.T) {
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -534,6 +542,7 @@ func testReceiverRateLimitSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -650,6 +659,7 @@ func testTrustedProxiesSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -763,6 +773,7 @@ func testAllowedEgressCIDRsSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -1006,6 +1017,7 @@ func assertMetricsAuthAccepted(t *testing.T, expectAuth bool) {
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide(globals.New, logger.New, config.New), fx.Provide(globals.New, logger.New, config.New),
fx.Populate(&cfg), fx.Populate(&cfg),
) )
+4 -13
View File
@@ -18,10 +18,9 @@ const testEnvKey = "WEBHOOKER_TEST_VALUE"
// Real configuration variables exercised by the config.New tests. // Real configuration variables exercised by the config.New tests.
const ( const (
envKeyPort = "PORT" envKeyPort = "PORT"
envKeyDebug = "DEBUG" envKeyDebug = "DEBUG"
envKeyMaintenanceMode = "MAINTENANCE_MODE" envKeyBindAddress = "BIND_ADDRESS"
envKeyBindAddress = "BIND_ADDRESS"
) )
// Sample BIND_ADDRESS values used by the tables below. // Sample BIND_ADDRESS values used by the tables below.
@@ -604,12 +603,6 @@ func flagEnvValueCases() []badEnvValueCase {
value: "ture", value: "ture",
expectError: true, expectError: true,
}, },
{
name: "unparseable MAINTENANCE_MODE aborts startup",
key: envKeyMaintenanceMode,
value: "sometimes",
expectError: true,
},
} }
} }
@@ -656,8 +649,7 @@ func TestNewUsesDefaultsWhenUnset(t *testing.T) {
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
for _, key := range []string{ for _, key := range []string{
envKeyPort, envKeyDebug, envKeyMaintenanceMode, envKeyPort, envKeyDebug, envKeyBindAddress, envKeySentryDSN,
envKeyBindAddress, envKeySentryDSN,
} { } {
require.NoError(t, os.Unsetenv(key)) require.NoError(t, os.Unsetenv(key))
} }
@@ -668,7 +660,6 @@ func TestNewUsesDefaultsWhenUnset(t *testing.T) {
assert.Equal(t, 8080, cfg.Port) assert.Equal(t, 8080, cfg.Port)
assert.False(t, cfg.Debug) assert.False(t, cfg.Debug)
assert.False(t, cfg.MaintenanceMode)
// Loopback, not the wildcard: the default must not publish the // Loopback, not the wildcard: the default must not publish the
// cleartext admin UI and the unauthenticated receiver on every // cleartext admin UI and the unauthenticated receiver on every
+3 -2
View File
@@ -15,6 +15,7 @@ type APIKey struct {
Description string `json:"description"` Description string `json:"description"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
// Relations // Relations. No model marshals the record it belongs to:
User User `json:"user,omitzero"` // User.APIKeys leads back here, and the JSON could loop.
User User `json:"-"`
} }
+5 -3
View File
@@ -56,8 +56,10 @@ type Delivery struct {
// the index. // the index.
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"` FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
// Relations // Relations. No model marshals the record it belongs to:
Event Event `json:"event,omitzero"` // Event.Deliveries and Target.Deliveries lead back here, and the
Target Target `json:"target,omitzero"` // JSON could loop.
Event Event `json:"-"`
Target Target `json:"-"`
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"` DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
} }
+3 -2
View File
@@ -23,6 +23,7 @@ type DeliveryResult struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Duration int64 `json:"durationMs"` // Duration in milliseconds Duration int64 `json:"durationMs"` // Duration in milliseconds
// Relations // Relations. No model marshals the record it belongs to:
Delivery Delivery `json:"delivery,omitzero"` // Delivery.DeliveryResults leads back here, and the JSON could loop.
Delivery Delivery `json:"-"`
} }
+3 -2
View File
@@ -15,6 +15,7 @@ type Entrypoint struct {
Description string `json:"description"` Description string `json:"description"`
Active bool `gorm:"default:true" json:"active"` Active bool `gorm:"default:true" json:"active"`
// Relations // Relations. No model marshals the record it belongs to:
Webhook Webhook `json:"webhook,omitzero"` // Webhook.Entrypoints leads back here, and the JSON could loop.
Webhook Webhook `json:"-"`
} }
+4 -3
View File
@@ -44,8 +44,9 @@ type Event struct {
// kept as the record of where the copy came from either way. // kept as the record of where the copy came from either way.
ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"` ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"`
// Relations // Relations. No model marshals the record it belongs to, so
Webhook Webhook `json:"webhook,omitzero"` // Webhook and Entrypoint are left out of the JSON.
Entrypoint Entrypoint `json:"entrypoint,omitzero"` Webhook Webhook `json:"-"`
Entrypoint Entrypoint `json:"-"`
Deliveries []Delivery `json:"deliveries,omitempty"` Deliveries []Delivery `json:"deliveries,omitempty"`
} }
+126
View File
@@ -0,0 +1,126 @@
package database_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// TestPreloadedModelsMarshalWithoutTheirParent pins that a child's
// reference to the record it belongs to is left out of the JSON, so a
// webhook and its targets cannot marshal each other in a loop, and that
// GORM still preloads that reference, since it ignores json tags.
func TestPreloadedModelsMarshalWithoutTheirParent(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
stored := database.Webhook{
UserID: uuid.New().String(),
Name: testWebhookName,
Entrypoints: []database.Entrypoint{{Path: uuid.New().String()}},
Targets: []database.Target{{
Name: "log",
Type: database.TargetTypeLog,
}},
}
require.NoError(t, db.Create(&stored).Error)
entrypointID := stored.Entrypoints[0].ID
targetID := stored.Targets[0].ID
var webhook database.Webhook
require.NoError(t, db.
Preload("Entrypoints.Webhook").
Preload("Targets.Webhook").
First(&webhook, "id = ?", stored.ID).Error)
require.Len(t, webhook.Entrypoints, 1)
require.Len(t, webhook.Targets, 1)
assert.Equal(t, stored.ID, webhook.Entrypoints[0].Webhook.ID)
assert.Equal(t, stored.ID, webhook.Targets[0].Webhook.ID)
encoded := marshalModel(t, webhook)
assert.Contains(t, encoded, entrypointID)
assert.Contains(t, encoded, targetID)
// Each child holds the parent's id as its webhookId, so the parent
// is looked for by its own id field.
parentIDField := `"id":"` + stored.ID + `"`
assert.NotContains(t, marshalModel(t, webhook.Entrypoints[0]), parentIDField)
assert.NotContains(t, marshalModel(t, webhook.Targets[0]), parentIDField)
var target database.Target
require.NoError(t, db.
Preload("Webhook").
First(&target, "id = ?", targetID).Error)
assert.Equal(t, stored.ID, target.Webhook.ID)
encoded = marshalModel(t, target)
assert.Contains(t, encoded, stored.ID)
assert.NotContains(t, encoded, parentIDField)
}
// TestModelsMarshalWithoutTheirParent covers the other references to a
// parent: each model is built with its parent set, and the parent's id
// must not appear in the JSON.
func TestModelsMarshalWithoutTheirParent(t *testing.T) {
t.Parallel()
parent := database.BaseModel{ID: uuid.New().String()}
cases := []struct {
name string
model any
}{
{
name: "Webhook.User",
model: database.Webhook{User: database.User{BaseModel: parent}},
},
{
name: "APIKey.User",
model: database.APIKey{User: database.User{BaseModel: parent}},
},
{
name: "Delivery.Event",
model: database.Delivery{Event: database.Event{BaseModel: parent}},
},
{
name: "Delivery.Target",
model: database.Delivery{Target: database.Target{BaseModel: parent}},
},
{
name: "DeliveryResult.Delivery",
model: database.DeliveryResult{
Delivery: database.Delivery{BaseModel: parent},
},
},
{
name: "Event.Webhook",
model: database.Event{Webhook: database.Webhook{BaseModel: parent}},
},
{
name: "Event.Entrypoint",
model: database.Event{
Entrypoint: database.Entrypoint{BaseModel: parent},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.NotContains(t, marshalModel(t, tc.model), parent.ID)
})
}
}
+3 -2
View File
@@ -34,7 +34,8 @@ type Target struct {
MaxRetries int `json:"maxRetries,omitempty"` MaxRetries int `json:"maxRetries,omitempty"`
MaxQueueSize int `json:"maxQueueSize,omitempty"` MaxQueueSize int `json:"maxQueueSize,omitempty"`
// Relations // Relations. No model marshals the record it belongs to:
Webhook Webhook `json:"webhook,omitzero"` // Webhook.Targets leads back here, and the JSON could loop.
Webhook Webhook `json:"-"`
Deliveries []Delivery `json:"deliveries,omitempty"` Deliveries []Delivery `json:"deliveries,omitempty"`
} }
+3 -2
View File
@@ -66,8 +66,9 @@ type Webhook struct {
// must equal DefaultRetentionDays. // must equal DefaultRetentionDays.
RetentionDays int `gorm:"default:30" json:"retentionDays"` RetentionDays int `gorm:"default:30" json:"retentionDays"`
// Relations // Relations. No model marshals the record it belongs to:
User User `json:"user,omitzero"` // User.Webhooks leads back here, and the JSON could loop.
User User `json:"-"`
Entrypoints []Entrypoint `json:"entrypoints,omitempty"` Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
Targets []Target `json:"targets,omitempty"` Targets []Target `json:"targets,omitempty"`
} }
+7 -15
View File
@@ -184,16 +184,6 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
wh := webhooks[i] wh := webhooks[i]
// Skip retain-forever webhooks before building any query.
// RetainsForever covers both the RetentionForeverDays
// sentinel and the non-positive values that predate it: the
// sentinel is a positive number, so without this the reaper
// would compute a cutoff a thousand years in the past and
// issue a DELETE matching nothing on every single sweep.
if wh.RetainsForever() {
continue
}
// Nothing to reap if the per-webhook database has never // Nothing to reap if the per-webhook database has never
// been created. // been created.
if !r.dbManager.DBExists(wh.ID) { if !r.dbManager.DBExists(wh.ID) {
@@ -212,6 +202,13 @@ func (r *RetentionReaper) reapWebhook(
webhookID string, webhookID string,
retentionDays int, retentionDays int,
) { ) {
// A retain-forever webhook has no cutoff, so its database is not
// even opened.
cutoff, ok := retentionCutoff(time.Now(), retentionDays)
if !ok {
return
}
db, err := r.dbManager.GetDB(webhookID) db, err := r.dbManager.GetDB(webhookID)
if err != nil { if err != nil {
r.log.Error( r.log.Error(
@@ -223,11 +220,6 @@ func (r *RetentionReaper) reapWebhook(
return return
} }
cutoff, ok := retentionCutoff(time.Now(), retentionDays)
if !ok {
return
}
deleted, err := reapExpired(ctx, db, cutoff) deleted, err := reapExpired(ctx, db, cutoff)
if err != nil { if err != nil {
r.log.Error( r.log.Error(
+1 -1
View File
@@ -362,7 +362,7 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
t, t,
overflowingRetentionDays, overflowingRetentionDays,
database.RetentionForeverDays, database.RetentionForeverDays,
"the test value must not be rescued by the forever skip", "the test value must not be treated as retain-forever",
) )
webhookID := createWebhook( webhookID := createWebhook(
+11 -4
View File
@@ -102,6 +102,13 @@ func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
// seedExpiredEvents stores count events created at the given time, // seedExpiredEvents stores count events created at the given time,
// each with a delivered delivery to one target and a failed delivery // each with a delivered delivery to one target and a failed delivery
// to the other, and one attempt for each delivery. // to the other, and one attempt for each delivery.
//
// It and seedBareEvents insert 50 rows per statement, not more. The
// SQLite driver looks up each parameter's value by scanning the
// statement's arguments from the first until it reaches that
// parameter's, so the time to bind a statement grows with the square of
// its parameter count: at 500 rows, several thousand parameters, the
// seeding took most of these tests' time under -race.
func seedExpiredEvents( func seedExpiredEvents(
t *testing.T, t *testing.T,
db *gorm.DB, db *gorm.DB,
@@ -138,8 +145,8 @@ func seedExpiredEvents(
) )
} }
require.NoError(t, db.CreateInBatches(events, 500).Error) require.NoError(t, db.CreateInBatches(events, 50).Error)
require.NoError(t, db.CreateInBatches(deliveries, 500).Error) require.NoError(t, db.CreateInBatches(deliveries, 50).Error)
results := make([]database.DeliveryResult, len(deliveries)) results := make([]database.DeliveryResult, len(deliveries))
for i := range deliveries { for i := range deliveries {
@@ -148,7 +155,7 @@ func seedExpiredEvents(
} }
} }
require.NoError(t, db.CreateInBatches(results, 500).Error) require.NoError(t, db.CreateInBatches(results, 50).Error)
} }
// seedBareEvents stores count events created at the given time, with // seedBareEvents stores count events created at the given time, with
@@ -172,7 +179,7 @@ func seedBareEvents(
events[i].CreatedAt = createdAt events[i].CreatedAt = createdAt
} }
require.NoError(t, db.CreateInBatches(events, 500).Error) require.NoError(t, db.CreateInBatches(events, 50).Error)
} }
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune // TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
+20 -5
View File
@@ -14,6 +14,7 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/lifecycle" "sneak.berlin/go/webhooker/internal/lifecycle"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics" "sneak.berlin/go/webhooker/internal/metrics"
@@ -149,8 +150,10 @@ type EngineParams struct {
DB *database.Database DB *database.Database
DBManager *database.WebhookDBManager DBManager *database.WebhookDBManager
Globals *globals.Globals
Logger *logger.Logger Logger *logger.Logger
SSRFGuard *Guard SSRFGuard *Guard
Metrics *metrics.Set
} }
// Engine processes queued deliveries in the background // Engine processes queued deliveries in the background
@@ -170,10 +173,14 @@ type Engine struct {
retryCh chan Task retryCh chan Task
workers int workers int
// mtr is the delivery metric set. Production wires the // version is the running build's version, the one the web UI
// process-wide one; a test can substitute a set registered on // footer shows. userAgent puts it on every outbound request.
// a private registry so its assertions are not disturbed by version string
// deliveries other tests are making at the same time.
// mtr is the delivery metric set. Production wires the one
// registered on the registry /metrics serves; a test can
// substitute a set registered on a registry it holds, so it can
// gather what its own deliveries recorded.
mtr *metrics.Set mtr *metrics.Set
// targets maps each target type to its implementation. // targets maps each target type to its implementation.
@@ -207,7 +214,8 @@ func New(
deliveryCh: make(chan Task, deliveryChannelSize), deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize), retryCh: make(chan Task, retryChannelSize),
workers: defaultWorkers, workers: defaultWorkers,
mtr: metrics.Default(), version: params.Globals.Version,
mtr: params.Metrics,
} }
e.initTargets(&http.Client{ e.initTargets(&http.Client{
@@ -330,6 +338,13 @@ func (e *Engine) ScheduleRetry(
}) })
} }
// userAgent is the User-Agent header of every http and slack
// delivery request: the program name and the running build's
// version.
func (e *Engine) userAgent() string {
return "webhooker/" + e.version
}
// registerHooks wires the engine's start and stop into the fx // registerHooks wires the engine's start and stop into the fx
// lifecycle. The start hook's context is deliberately ignored // lifecycle. The start hook's context is deliberately ignored
// (see start for why the worker pool must not inherit it); the // (see start for why the worker pool must not inherit it); the
+1 -5
View File
@@ -1239,11 +1239,6 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
testContentType, testContentType,
receivedHeaders.Get("Content-Type"), receivedHeaders.Get("Content-Type"),
) )
assert.Equal(t,
"webhooker/1.0",
receivedHeaders.Get("User-Agent"),
)
} }
// The event's stored inbound headers carry the same Content-Type the // The event's stored inbound headers carry the same Content-Type the
@@ -1312,6 +1307,7 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
ContentType: tc.event, ContentType: tc.event,
}, },
cfg, cfg,
"webhooker/dev",
) )
assert.Equal(t, assert.Equal(t,
+13 -6
View File
@@ -9,6 +9,7 @@ import (
"net/url" "net/url"
"time" "time"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
@@ -82,8 +83,9 @@ func ExportApplyRequestHeaders(
req *http.Request, req *http.Request,
event *database.Event, event *database.Event,
cfg *HTTPTargetConfig, cfg *HTTPTargetConfig,
userAgent string,
) []string { ) []string {
return applyRequestHeaders(req, event, cfg) return applyRequestHeaders(req, event, cfg, userAgent)
} }
// ExportTruncate exposes truncate for testing. // ExportTruncate exposes truncate for testing.
@@ -399,7 +401,7 @@ func NewTestEngine(
deliveryCh: make(chan Task, deliveryChannelSize), deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize), retryCh: make(chan Task, retryChannelSize),
workers: workers, workers: workers,
mtr: metrics.Default(), mtr: metrics.New(prometheus.NewRegistry()),
} }
e.initTargets(client) e.initTargets(client)
@@ -414,7 +416,7 @@ func NewTestEngineSmallRetry(
e := &Engine{ e := &Engine{
log: log, log: log,
retryCh: make(chan Task, 1), retryCh: make(chan Task, 1),
mtr: metrics.Default(), mtr: metrics.New(prometheus.NewRegistry()),
} }
e.initTargets(nil) e.initTargets(nil)
@@ -437,7 +439,7 @@ func NewTestEngineWithDB(
deliveryCh: make(chan Task, deliveryChannelSize), deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize), retryCh: make(chan Task, retryChannelSize),
workers: workers, workers: workers,
mtr: metrics.Default(), mtr: metrics.New(prometheus.NewRegistry()),
} }
e.initTargets(client) e.initTargets(client)
@@ -445,8 +447,7 @@ func NewTestEngineWithDB(
} }
// ExportSetMetrics substitutes the engine's metric set, so a test can // ExportSetMetrics substitutes the engine's metric set, so a test can
// assert on collectors registered on a private registry instead of // assert on collectors registered on a registry it holds.
// the process-wide ones every other test is also moving.
func (e *Engine) ExportSetMetrics(mtr *metrics.Set) { func (e *Engine) ExportSetMetrics(mtr *metrics.Set) {
e.mtr = mtr e.mtr = mtr
} }
@@ -511,6 +512,12 @@ func (e *ExportArchiveWriter) Reopen(
return e.w.reopen(expiry) return e.w.reopen(expiry)
} }
// SetNow replaces the clock the writer measures its reopen
// debounce on.
func (e *ExportArchiveWriter) SetNow(now func() time.Time) {
e.w.now = now
}
// Reopens reports how many times the file has been opened. // Reopens reports how many times the file has been opened.
func (e *ExportArchiveWriter) Reopens() int { func (e *ExportArchiveWriter) Reopens() int {
return e.w.reopens return e.w.reopens
+2 -3
View File
@@ -35,9 +35,8 @@ const (
) )
// mIsolate gives the setup's engine a metric set registered on a // mIsolate gives the setup's engine a metric set registered on a
// private registry. The process-wide collectors are moved by every // registry this test holds, so its exact assertions can gather from
// other delivery test running in parallel, so exact assertions are // it.
// only possible against a registry this test owns.
func mIsolate( func mIsolate(
t *testing.T, s iSetup, t *testing.T, s iSetup,
) *prometheus.Registry { ) *prometheus.Registry {
+1
View File
@@ -375,6 +375,7 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
"Content-Type": testContentType, "Content-Type": testContentType,
}, },
}, },
"webhooker/dev",
) )
assert.Equal(t, assert.Equal(t,
+57 -13
View File
@@ -37,8 +37,8 @@ var (
"blocked cloud metadata address", "blocked cloud metadata address",
) )
errBlockedMetadata = errors.New( errBlockedMetadata = errors.New(
"blocked link-local or cloud instance metadata " + "blocked link-local, cloud instance metadata or " +
"address: ALLOWED_EGRESS_CIDRS cannot open it", "unspecified address: ALLOWED_EGRESS_CIDRS cannot open it",
) )
errInvalidScheme = errors.New( errInvalidScheme = errors.New(
"only http and https are allowed", "only http and https are allowed",
@@ -72,14 +72,17 @@ var blockedNetworks []*net.IPNet
var blockedPublicNetworks []*net.IPNet var blockedPublicNetworks []*net.IPNet
// alwaysBlockedNetworks are the ranges no configuration can // alwaysBlockedNetworks are the ranges no configuration can
// open: the link-local blocks and the cloud instance metadata // open, so a supplied CIDR that covers one still leaves it
// endpoints that live outside them. Reaching one is credential // blocked. An entry is here for one of two reasons: it is a
// or user-data theft rather than delivery to an internal // metadata endpoint (the link-local blocks and the cloud
// service, so a supplied CIDR that covers such an address still // instance metadata endpoints that live outside them), or it is
// leaves it blocked. // an unspecified address. Reaching a metadata endpoint is
// credential or user-data theft rather than delivery to an
// internal service.
// //
// Inclusion criterion — an address belongs here only if BOTH // Inclusion criterion for metadata endpoints — one belongs here
// hold, and every entry below satisfies both: // only if BOTH hold, and every metadata entry below satisfies
// both:
// //
// 1. It is a fixed address assigned by the provider, or a // 1. It is a fixed address assigned by the provider, or a
// range reserved by IANA — never one the operator chose. // range reserved by IANA — never one the operator chose.
@@ -90,8 +93,8 @@ var blockedPublicNetworks []*net.IPNet
// not cheaply rotated. // not cheaply rotated.
// //
// Both halves are load-bearing, so use them to refuse a // Both halves are load-bearing, so use them to refuse a
// candidate and say why. An endpoint disclosing only the // metadata candidate and say why. An endpoint disclosing only
// operator's own inventory (instance id, region, disks, NICs) // the operator's own inventory (instance id, region, disks, NICs)
// fails (2): letting a delivery target reach the operator's own // fails (2): letting a delivery target reach the operator's own
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to // infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
// provide. But (2) is not "IAM credentials only" either — // provide. But (2) is not "IAM credentials only" either —
@@ -112,6 +115,15 @@ var blockedPublicNetworks []*net.IPNet
// This is a criterion, not an enumeration of every metadata // This is a criterion, not an enumeration of every metadata
// address in existence. // address in existence.
// //
// The unspecified addresses 0.0.0.0 and :: are here for a
// separate reason: they disclose nothing, but no host can have
// either, and on Linux a connection to one reaches this host's
// own loopback. Listing them means an allowlist reaches loopback
// only through an entry that covers a loopback address
// (127.0.0.0/8, ::1/128, 0.0.0.0/0), never through one that
// covers only 0.0.0.0 or :: (0.0.0.0/8, for example). Nothing
// else lives at either address, so refusing them costs nothing.
//
// Every entry is either already in blockedNetworks — this list is // Every entry is either already in blockedNetworks — this list is
// what makes it unconditional — or an alternate encoding of // what makes it unconditional — or an alternate encoding of
// 169.254.169.254 that Contains does not match against // 169.254.169.254 that Contains does not match against
@@ -131,23 +143,46 @@ var alwaysBlockedNetworks []*net.IPNet
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup //nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
func init() { func init() {
blockedNetworks = mustParseCIDRs([]string{ blockedNetworks = mustParseCIDRs([]string{
// IPv4 loopback.
"127.0.0.0/8", "127.0.0.0/8",
// RFC 1918 private network.
"10.0.0.0/8", "10.0.0.0/8",
// RFC 1918 private network.
"172.16.0.0/12", "172.16.0.0/12",
// RFC 1918 private network.
"192.168.0.0/16", "192.168.0.0/16",
// IPv4 link-local.
"169.254.0.0/16", "169.254.0.0/16",
// "This network", holding the IPv4 unspecified address 0.0.0.0.
"0.0.0.0/8", "0.0.0.0/8",
// Carrier-grade NAT shared address space.
"100.64.0.0/10", "100.64.0.0/10",
// IETF protocol assignments.
"192.0.0.0/24", "192.0.0.0/24",
// IPv4 documentation (TEST-NET-1).
"192.0.2.0/24", "192.0.2.0/24",
// Benchmarking.
"198.18.0.0/15", "198.18.0.0/15",
// IPv4 documentation (TEST-NET-2).
"198.51.100.0/24", "198.51.100.0/24",
// IPv4 documentation (TEST-NET-3).
"203.0.113.0/24", "203.0.113.0/24",
// IPv4 multicast.
"224.0.0.0/4", "224.0.0.0/4",
// Reserved, including the broadcast address.
"240.0.0.0/4", "240.0.0.0/4",
// IPv6 loopback.
"::1/128", "::1/128",
// IPv6 unspecified address.
"::/128",
// IPv6 unique local addresses.
"fc00::/7", "fc00::/7",
// IPv6 link-local.
"fe80::/10", "fe80::/10",
// IPv6 multicast.
"ff00::/8",
// IPv6 documentation.
"2001:db8::/32",
}) })
blockedPublicNetworks = mustParseCIDRs([]string{ blockedPublicNetworks = mustParseCIDRs([]string{
@@ -207,6 +242,14 @@ func init() {
// allowlist from opening it. // allowlist from opening it.
"192.0.0.192/32", "192.0.0.192/32",
// The unspecified addresses, each of which reaches this
// host's loopback on Linux.
//
// IPv4 unspecified address, inside the blocked 0.0.0.0/8.
"0.0.0.0/32",
// IPv6 unspecified address.
"::/128",
// 169.254.169.254 as an IPv4-compatible IPv6 address. // 169.254.169.254 as an IPv4-compatible IPv6 address.
"::a9fe:a9fe/128", "::a9fe:a9fe/128",
// 169.254.169.254 behind the NAT64 well-known prefix. // 169.254.169.254 behind the NAT64 well-known prefix.
@@ -343,8 +386,9 @@ func (g *Guard) allows(ip net.IP) bool {
// The order is the policy: // The order is the policy:
// //
// 1. alwaysBlockedNetworks is refused before the allowlist is // 1. alwaysBlockedNetworks is refused before the allowlist is
// consulted, so no configured CIDR reaches link-local or a // consulted, so no configured CIDR reaches link-local, a
// cloud metadata endpoint at a non-public address. // cloud metadata endpoint at a non-public address, or an
// unspecified address.
// 2. The allowlist is consulted next, so a listed private // 2. The allowlist is consulted next, so a listed private
// network, or a listed public address on the default // network, or a listed public address on the default
// blocklist, becomes reachable. // blocklist, becomes reachable.
+39 -11
View File
@@ -168,12 +168,13 @@ func TestGuardAllowlist_UnlistedPrivateStillRefused(t *testing.T) {
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing // TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
// case: cloud instance metadata endpoints are credential theft // case: cloud instance metadata endpoints are credential theft
// rather than delivery to an internal service, so no allowlist // rather than delivery to an internal service, and the
// reaches one. Every guard below names a CIDR that covers its // unspecified addresses 0.0.0.0 and :: reach this host's loopback
// target — including 0.0.0.0/0, ::/0, and the ordinary ULA and // on Linux, so no allowlist reaches any of them. Every guard
// CGNAT blocks an operator would really list — and the address // below names a CIDR that covers its target — including
// must stay refused anyway, on both the validation and the // 0.0.0.0/0, ::/0, and the ordinary ULA and CGNAT blocks an
// delivery path. // operator would really list — and the address must stay
// refused anyway, on both the validation and the delivery path.
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) { func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
t.Parallel() t.Parallel()
@@ -219,15 +220,17 @@ type metadataAlwaysRefusedCase struct {
} }
// metadataAlwaysRefusedCases enumerates every unconditionally // metadataAlwaysRefusedCases enumerates every unconditionally
// blocked address together with an allowlist entry that would // blocked address (link-local, the cloud metadata endpoints and
// otherwise reach it. Split by family of address only to stay // the unspecified addresses) together with an allowlist entry
// under the function-length limit. // that would otherwise reach it. Split by family of address only
// to stay under the function-length limit.
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase { func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
cases := linkLocalRefusedCases() cases := linkLocalRefusedCases()
cases = append(cases, ulaMetadataRefusedCases()...) cases = append(cases, ulaMetadataRefusedCases()...)
cases = append(cases, ipv4MetadataRefusedCases()...) cases = append(cases, ipv4MetadataRefusedCases()...)
cases = append(cases, encodedMetadataRefusedCases()...)
return append(cases, encodedMetadataRefusedCases()...) return append(cases, unspecifiedRefusedCases()...)
} }
// linkLocalRefusedCases covers the link-local blocks, including // linkLocalRefusedCases covers the link-local blocks, including
@@ -367,6 +370,23 @@ func encodedMetadataRefusedCases() []metadataAlwaysRefusedCase {
} }
} }
// unspecifiedRefusedCases covers the unspecified addresses, each
// of which reaches this host's loopback on Linux.
func unspecifiedRefusedCases() []metadataAlwaysRefusedCase {
return []metadataAlwaysRefusedCase{
{
name: "IPv4 unspecified address under 0.0.0.0/0",
allow: allowAllIPv4,
target: "http://0.0.0.0:8080/hook",
},
{
name: "IPv6 unspecified address under ::/0",
allow: allowAllIPv6,
target: "http://[::]:8080/hook",
},
}
}
// TestGuardAllowlist_PublicUnaffected asserts the allowlist does // TestGuardAllowlist_PublicUnaffected asserts the allowlist does
// not narrow anything: public addresses were reachable before it // not narrow anything: public addresses were reachable before it
// existed and stay reachable, whether or not a list is set. // existed and stay reachable, whether or not a list is set.
@@ -524,6 +544,10 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
// Oracle Cloud Classic metadata, inside the blocked // Oracle Cloud Classic metadata, inside the blocked
// 192.0.0.0/24. // 192.0.0.0/24.
"192.0.0.192/32", "192.0.0.192/32",
// The IPv4 and IPv6 unspecified addresses, each of
// which reaches this host's loopback on Linux.
"0.0.0.0/32",
"::/128",
// 169.254.169.254 as an IPv4-compatible IPv6 address. // 169.254.169.254 as an IPv4-compatible IPv6 address.
"::a9fe:a9fe/128", "::a9fe:a9fe/128",
// 169.254.169.254 behind the NAT64 well-known prefix. // 169.254.169.254 behind the NAT64 well-known prefix.
@@ -556,7 +580,8 @@ func TestDefaultBlocklist_PinnedSet(t *testing.T) {
{cidr: "172.16.0.0/12", reopenable: true}, {cidr: "172.16.0.0/12", reopenable: true},
{cidr: "192.168.0.0/16", reopenable: true}, {cidr: "192.168.0.0/16", reopenable: true},
{cidr: linkLocalIPv4, reopenable: false}, {cidr: linkLocalIPv4, reopenable: false},
{cidr: "0.0.0.0/8", reopenable: true}, // Its first address, 0.0.0.0, is in the unconditional set.
{cidr: "0.0.0.0/8", reopenable: false},
{cidr: "100.64.0.0/10", reopenable: true}, {cidr: "100.64.0.0/10", reopenable: true},
{cidr: "192.0.0.0/24", reopenable: true}, {cidr: "192.0.0.0/24", reopenable: true},
{cidr: "192.0.2.0/24", reopenable: true}, {cidr: "192.0.2.0/24", reopenable: true},
@@ -566,8 +591,11 @@ func TestDefaultBlocklist_PinnedSet(t *testing.T) {
{cidr: "224.0.0.0/4", reopenable: true}, {cidr: "224.0.0.0/4", reopenable: true},
{cidr: "240.0.0.0/4", reopenable: true}, {cidr: "240.0.0.0/4", reopenable: true},
{cidr: "::1/128", reopenable: true}, {cidr: "::1/128", reopenable: true},
{cidr: "::/128", reopenable: false},
{cidr: "fc00::/7", reopenable: true}, {cidr: "fc00::/7", reopenable: true},
{cidr: "fe80::/10", reopenable: false}, {cidr: "fe80::/10", reopenable: false},
{cidr: "ff00::/8", reopenable: true},
{cidr: "2001:db8::/32", reopenable: true},
{cidr: "168.63.129.16/32", public: true, reopenable: true}, {cidr: "168.63.129.16/32", public: true, reopenable: true},
} }
+36
View File
@@ -101,6 +101,42 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
} }
} }
// TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation
// covers the unspecified addresses and the IPv6 multicast and
// documentation ranges: with no allowlist set, each is refused
// both when a target is created and when a delivery dials it.
func TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation(
t *testing.T,
) {
t.Parallel()
guard := delivery.NewTestGuard()
targets := []string{
// The unspecified addresses. On Linux a connection to
// either reaches this host's loopback.
"http://0.0.0.0:8080/hook",
"http://[::]:8080/hook",
// IPv6 multicast, all nodes.
"http://[ff02::1]/hook",
// IPv6 documentation.
"http://[2001:db8::1]/hook",
}
for _, target := range targets {
t.Run(target, func(t *testing.T) {
t.Parallel()
require.Error(t,
guard.ValidateTargetURL(context.Background(), target),
"%s must be refused at target creation", target,
)
assertDialRefused(t, guard, target)
})
}
}
func TestValidateTargetURL_Allowed(t *testing.T) { func TestValidateTargetURL_Allowed(t *testing.T) {
t.Parallel() t.Parallel()
+29 -8
View File
@@ -194,6 +194,10 @@ type archiveWriter struct {
lastReopen time.Time lastReopen time.Time
reopens int reopens int
// now is the clock the reopen debounce is measured on. It is
// time.Now outside tests.
now func() time.Time
// evicted marks a writer that has been removed from the // evicted marks a writer that has been removed from the
// registry. Its handle is closed and it must never open the // registry. Its handle is closed and it must never open the
// file again: nothing holds it any more, so a reopen would // file again: nothing holds it any more, so a reopen would
@@ -228,6 +232,7 @@ func newArchiveWriter(
path: path, path: path,
log: log, log: log,
debounce: archiveReopenDebounce, debounce: archiveReopenDebounce,
now: time.Now,
} }
} }
@@ -263,7 +268,7 @@ func (w *archiveWriter) write(
) )
} }
if time.Since(w.lastReopen) >= w.debounce { if w.now().Sub(w.lastReopen) >= w.debounce {
return w.reopen(expiry) return w.reopen(expiry)
} }
@@ -323,7 +328,7 @@ func (w *archiveWriter) openMode(
} }
w.db = gdb w.db = gdb
w.lastReopen = time.Now() w.lastReopen = w.now()
w.reopens++ w.reopens++
if expiry > 0 { if expiry > 0 {
@@ -407,7 +412,9 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
// creates it under the new name. // creates it under the new name.
// //
// If a file already has the new name, nothing is moved and the // If a file already has the new name, nothing is moved and the
// error is ErrArchiveNameTaken. // error is ErrArchiveNameTaken. If one file fails to move, those
// already moved are moved back before the error is returned, so the
// archive is never split across two names.
func (w *archiveWriter) rename(name string) error { func (w *archiveWriter) rename(name string) error {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -435,13 +442,27 @@ func (w *archiveWriter) rename(name string) error {
w.close() w.close()
for _, suffix := range suffixes { for i, suffix := range suffixes {
err := os.Rename(w.path+suffix, path+suffix) err := os.Rename(w.path+suffix, path+suffix)
if err != nil && !errors.Is(err, fs.ErrNotExist) { if err == nil || errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf( continue
"renaming archive %s to %s: %w", w.path, path, err,
)
} }
for _, moved := range suffixes[:i] {
backErr := os.Rename(path+moved, w.path+moved)
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
w.log.Error(
"failed to move archive file back",
"from", path+moved,
"to", w.path+moved,
"error", backErr,
)
}
}
return fmt.Errorf(
"renaming archive %s to %s: %w", w.path+suffix, path+suffix, err,
)
} }
w.path = path w.path = path
+86 -36
View File
@@ -184,13 +184,20 @@ func TestArchiveWriter_RecreatesAfterRemoval(
func TestArchiveWriter_ReopenDebounce(t *testing.T) { func TestArchiveWriter_ReopenDebounce(t *testing.T) {
t.Parallel() t.Parallel()
// A generous debounce keeps the two rapid writes inside const debounce = 2 * time.Second
// the window even on a heavily loaded test machine.
path := filepath.Join(t.TempDir(), "archive-wh.db") path := filepath.Join(t.TempDir(), "archive-wh.db")
w := delivery.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 2*time.Second, path, archiveTestLogger(), debounce,
) )
// The writer measures its reopen debounce on this clock, which
// only the test moves, so how long the host takes between
// writes cannot change the result.
now := time.Now()
w.SetNow(func() time.Time { return now })
require.NoError(t, w.Write( require.NoError(t, w.Write(
delivery.ExportArchivedEvent{EventID: "a"}, 0, delivery.ExportArchivedEvent{EventID: "a"}, 0,
)) ))
@@ -202,7 +209,7 @@ func TestArchiveWriter_ReopenDebounce(t *testing.T) {
// initial open — no extra close/reopen. // initial open — no extra close/reopen.
assert.Equal(t, 1, w.Reopens()) assert.Equal(t, 1, w.Reopens())
time.Sleep(2100 * time.Millisecond) now = now.Add(debounce)
require.NoError(t, w.Write( require.NoError(t, w.Write(
delivery.ExportArchivedEvent{EventID: "c"}, 0, delivery.ExportArchivedEvent{EventID: "c"}, 0,
@@ -521,47 +528,58 @@ func TestRename_MovesTheFile(t *testing.T) {
) )
} }
// TestRename_NeverReplacesAFile plants a file at the new name and // TestRename_NeverReplacesAFile plants a file at the new name, once
// proves the rename is refused, the planted file survives, and the // the .db alone, once a lone -wal and once a lone -shm, and proves
// archive keeps its name and its rows. // each time that the rename is refused, the planted file survives,
// and the archive keeps its name and its rows.
func TestRename_NeverReplacesAFile(t *testing.T) { func TestRename_NeverReplacesAFile(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) for _, suffix := range archiveFileSuffixes() {
tgt := env.seedDatabaseTarget(t, "") t.Run("planted .db"+suffix, func(t *testing.T) {
oldPath := env.archivePath(tgt) t.Parallel()
webhookDB := testWebhookDB(t) env := setupArchiveTest(t)
first := seedEvent(t, webhookDB, `{"n":1}`) tgt := env.seedDatabaseTarget(t, "")
env.eng.ExportDeliverDatabase( oldPath := env.archivePath(tgt)
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
)
newPath := filepath.Join( webhookDB := testWebhookDB(t)
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", first := seedEvent(t, webhookDB, `{"n":1}`)
) env.eng.ExportDeliverDatabase(
require.NoError(t, os.WriteFile(newPath, []byte("planted"), 0o600)) webhookDB,
seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
)
require.ErrorIs( newPath := filepath.Join(
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"), env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
delivery.ErrArchiveNameTaken, )
) plantedPath := newPath + suffix
require.NoError(
t, os.WriteFile(plantedPath, []byte("planted"), 0o600),
)
//nolint:gosec // reads the file the test planted under t.TempDir() require.ErrorIs(
planted, err := os.ReadFile(newPath) t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
require.NoError(t, err) delivery.ErrArchiveNameTaken,
assert.Equal(t, "planted", string(planted)) )
second := seedEvent(t, webhookDB, `{"n":2}`) //nolint:gosec // reads the file the test planted under t.TempDir()
env.eng.ExportDeliverDatabase( planted, err := os.ReadFile(plantedPath)
webhookDB, require.NoError(t, err)
seedDatabaseTargetDelivery(t, webhookDB, second, tgt), assert.Equal(t, "planted", string(planted))
)
assert.ElementsMatch( second := seedEvent(t, webhookDB, `{"n":2}`)
t, []string{first.ID, second.ID}, env.eng.ExportDeliverDatabase(
archivedEventIDs(t, oldPath), webhookDB,
) seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
)
assert.ElementsMatch(
t, []string{first.ID, second.ID},
archivedEventIDs(t, oldPath),
)
})
}
} }
// TestRename_BeforeTheNameIsSaved covers the order the handlers // TestRename_BeforeTheNameIsSaved covers the order the handlers
@@ -624,3 +642,35 @@ func TestArchiveWriter_RenameMovesSidecars(t *testing.T) {
assert.Equal(t, newPath, w.Path()) assert.Equal(t, newPath, w.Path())
} }
// TestArchiveWriter_RenameMovesBackOnFailure makes the -wal fail to
// move after the .db has moved, and proves the .db is moved back, so
// the archive is never split across two names. The new name is 255
// bytes, the longest a file name may be, so the .db can take it but
// the -wal, four bytes longer, cannot.
func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
newName := strings.Repeat("a", 252) + ".db"
for _, suffix := range archiveFileSuffixes() {
require.NoError(
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
)
}
w := delivery.NewExportArchiveWriter(
oldPath, archiveTestLogger(), 0,
)
require.Error(t, w.Rename(newName))
for _, suffix := range archiveFileSuffixes() {
assert.FileExists(t, oldPath+suffix)
}
assert.NoFileExists(t, filepath.Join(dir, newName))
assert.Equal(t, oldPath, w.Path())
}
+7 -2
View File
@@ -442,7 +442,9 @@ func (t *httpTarget) doHTTPRequest(
) )
} }
originScoped := applyRequestHeaders(req, event, cfg) originScoped := applyRequestHeaders(
req, event, cfg, t.eng.userAgent(),
)
client := t.clientForRequest(cfg, originScoped) client := t.clientForRequest(cfg, originScoped)
@@ -562,10 +564,13 @@ func isForwardableHeader(name string) bool {
// Content-Type goes out once: a Content-Type configured on the target // Content-Type goes out once: a Content-Type configured on the target
// wins, otherwise the event's ContentType, otherwise none. The inbound // wins, otherwise the event's ContentType, otherwise none. The inbound
// Content-Type in the event's headers is never forwarded. // Content-Type in the event's headers is never forwarded.
//
// userAgent is set last, over any configured or inbound User-Agent.
func applyRequestHeaders( func applyRequestHeaders(
req *http.Request, req *http.Request,
event *database.Event, event *database.Event,
cfg *HTTPTargetConfig, cfg *HTTPTargetConfig,
userAgent string,
) []string { ) []string {
if event.ContentType != "" { if event.ContentType != "" {
req.Header.Set( req.Header.Set(
@@ -580,7 +585,7 @@ func applyRequestHeaders(
originScoped[http.CanonicalHeaderKey(k)] = struct{}{} originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
} }
req.Header.Set("User-Agent", "webhooker/1.0") req.Header.Set("User-Agent", userAgent)
// A Content-Type configured on the target describes the body // A Content-Type configured on the target describes the body
// being sent rather than the sender. A 307/308 preserves the // being sent rather than the sender. A 307/308 preserves the
+1 -1
View File
@@ -136,7 +136,7 @@ func (t *slackTarget) attempt(
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "webhooker/1.0") req.Header.Set("User-Agent", t.eng.userAgent())
resp, doErr := executeHTTPRequest(t.client, req) resp, doErr := executeHTTPRequest(t.client, req)
durationMs := time.Since(start).Milliseconds() durationMs := time.Since(start).Milliseconds()
+91
View File
@@ -0,0 +1,91 @@
package delivery_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
)
// Both the http and the slack target send webhooker/ and the version
// in Globals, the value the web UI footer shows. A User-Agent
// configured on the target or carried in by the sender does not
// replace it.
func TestUserAgent_IsTheBuildVersion(t *testing.T) {
t.Parallel()
const want = "webhooker/1.2.3-test"
userAgents := make(chan string, 1)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
userAgents <- r.Header.Get("User-Agent")
w.WriteHeader(http.StatusOK)
},
))
defer ts.Close()
g := &globals.Globals{Version: "1.2.3-test"}
lc := fxtest.NewLifecycle(t)
log, err := logger.New(lc, logger.LoggerParams{Globals: g})
require.NoError(t, err)
e := delivery.New(lc, delivery.EngineParams{
Globals: g,
Logger: log,
// httptest listens on loopback, which the default guard
// refuses.
SSRFGuard: delivery.NewTestGuard(
netip.MustParsePrefix("127.0.0.0/8"),
),
Metrics: metrics.New(prometheus.NewRegistry()),
})
statusCode, _, _, err := e.ExportDoHTTPRequest(
context.Background(),
&delivery.HTTPTargetConfig{
URL: ts.URL,
Headers: map[string]string{"User-Agent": "configured/1"},
},
&database.Event{Headers: `{"User-Agent":["curl/8"]}`},
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, statusCode)
require.Len(t, userAgents, 1, "the http target sent no request")
assert.Equal(t, want, <-userAgents, "http target")
db := testWebhookDB(t)
targetID := uuid.New().String()
slackCfg, err := json.Marshal(
delivery.SlackTargetConfig{WebhookURL: ts.URL},
)
require.NoError(t, err)
event := seedEvent(t, db, `{"action":"test"}`)
dlv := seedDelivery(
t, db, event.ID, targetID, database.DeliveryStatusPending,
)
e.ExportDeliverSlack(context.Background(), db, buildSlackDelivery(
dlv, event, targetID, "test-slack", string(slackCfg),
))
require.Len(t, userAgents, 1, "the slack target sent no request")
assert.Equal(t, want, <-userAgents, "slack target")
}
+4
View File
@@ -137,6 +137,10 @@ func bootAtDebug(t *testing.T, dataDir string) string {
app := fxtest.New( app := fxtest.New(
t, t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned.
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
+5 -4
View File
@@ -139,8 +139,7 @@ func (h *Handlers) renderLoginError(
), ),
} }
w.WriteHeader(status) h.renderTemplateStatus(w, r, "login.html", data, status)
h.renderTemplate(w, r, "login.html", data)
} }
// authenticateUser looks up and verifies a user's credentials. // authenticateUser looks up and verifies a user's credentials.
@@ -333,7 +332,9 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
) )
} }
// Redirect to login page http.Redirect(
http.Redirect(w, r, "/pages/login", http.StatusSeeOther) w, r, withNotice("/pages/login", signedOut),
http.StatusSeeOther,
)
} }
} }
+55
View File
@@ -3,6 +3,7 @@ package handlers_test
import ( import (
"context" "context"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -404,6 +405,60 @@ func TestLogin_MissingCredentialsRejectedBeforeAnyHash(t *testing.T) {
) )
} }
// TestLogin_FormErrorAnswersItsStatusWithThePage proves that the login
// form shown again with an error still answers 400 with the whole page.
func TestLogin_FormErrorAnswersItsStatusWithThePage(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
w := submitLogin(h, sharedProxyPeer, "", "")
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(
t, w.Body.String(), "Username and password are required",
)
assert.Contains(
t, w.Body.String(), "</html>",
"the page must render to completion",
)
}
// TestLogin_FormErrorRenderFailureAnswers500 proves that a login form
// error page whose template fails answers 500 with the error page and
// none of the form page, rather than the 400 it meant to send.
func TestLogin_FormErrorRenderFailureAnswers500(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
// The page prints its error message and then fails.
h.AddTemplateForTest("login.html", template.Must(
template.New("login").Funcs(template.FuncMap{
"fail": func() (string, error) { return "", errMidRender },
}).Parse(`{{.Error}}{{fail}}`),
))
w := submitLogin(h, sharedProxyPeer, "", "")
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NotContains(
t, w.Body.String(), "Username and password are required",
"the response must carry no part of the aborted page",
)
assert.Contains(t, w.Body.String(), "500 Internal Server Error")
}
// TestLogin_SuccessCreatesSession is the control for the tests above: // TestLogin_SuccessCreatesSession is the control for the tests above:
// the success path they assert on really does authenticate. // the success path they assert on really does authenticate.
func TestLogin_SuccessCreatesSession(t *testing.T) { func TestLogin_SuccessCreatesSession(t *testing.T) {
+19 -55
View File
@@ -11,72 +11,37 @@ import (
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
// replayOutcomeParam is the query parameter the replay POST redirects // The outcomes of a replay POST, as the notice codes its redirect
// with and the event log page reads its banner from. // carries. noticeFor holds the line each one shows.
const replayOutcomeParam = "replay"
// replayOutcomeCode is the outcome of a replay POST. The redirect
// carries one of these fixed codes rather than a message, so nothing a
// client submits can reach the rendered page through it.
type replayOutcomeCode string
const ( const (
// replayQueued reports that a new delivery was created and handed // replayQueued reports that a new delivery was created and handed
// to the delivery engine. // to the delivery engine.
replayQueued replayOutcomeCode = "queued" replayQueued noticeCode = "replay-queued"
// replayTargetDeleted reports a target that once existed and has // replayTargetDeleted reports a target that once existed and has
// since been deleted. Deletes are soft and deliveries carry no // since been deleted. Deletes are soft and deliveries carry no
// foreign key to the target row, so the history survives its // foreign key to the target row, so the history survives its
// target and this is the ordinary case for an old event. // target and this is the ordinary case for an old event.
replayTargetDeleted replayOutcomeCode = "target-deleted" replayTargetDeleted noticeCode = "replay-target-deleted"
// replayTargetMissing reports a target id that names no row at // replayTargetMissing reports a target id that names no row at
// all, deleted or otherwise. // all, deleted or otherwise.
replayTargetMissing replayOutcomeCode = "target-missing" replayTargetMissing noticeCode = "replay-target-missing"
// replayTargetInactive reports a target the operator has // replayTargetInactive reports a target the operator has
// deactivated. A deactivated target receives no new deliveries, so // deactivated. A deactivated target receives no new deliveries, so
// a replay to it would be a delivery they switched off. // a replay to it would be a delivery they switched off.
replayTargetInactive replayOutcomeCode = "target-inactive" replayTargetInactive noticeCode = "replay-target-inactive"
// replayNotTerminal reports a delivery the engine has not finished // replayNotTerminal reports a delivery the engine has not finished
// with. // with.
replayNotTerminal replayOutcomeCode = "not-terminal" replayNotTerminal noticeCode = "replay-not-terminal"
// replayInFlight reports that an earlier replay of this event to // replayInFlight reports that an earlier replay of this event to
// this target is still running. // this target is still running.
replayInFlight replayOutcomeCode = "in-flight" replayInFlight noticeCode = "replay-in-flight"
) )
// replayOutcome returns the banner the event log page shows for an
// outcome code, and whether the replay was queued. An unrecognised
// code yields no banner.
func replayOutcome(code string) (string, bool) {
switch replayOutcomeCode(code) {
case replayQueued:
return "Replay queued: a new delivery was created against " +
"the target's current configuration.", true
case replayTargetDeleted:
return "Not replayed: the target this delivery was for has " +
"been deleted. Recreate the target, then replay.", false
case replayTargetMissing:
return "Not replayed: the target this delivery was for no " +
"longer exists.", false
case replayTargetInactive:
return "Not replayed: the target this delivery was for is " +
"deactivated. Activate it, then replay.", false
case replayNotTerminal:
return "Not replayed: this delivery has not finished yet.",
false
case replayInFlight:
return "Not replayed: a delivery of this event to this " +
"target is already in flight.", false
default:
return "", false
}
}
// HandleDeliveryReplay re-sends a finished delivery's event to its // HandleDeliveryReplay re-sends a finished delivery's event to its
// target. // target.
// //
@@ -140,14 +105,14 @@ func (h *Handlers) replayDelivery(
} }
if !original.Status.Terminal() { if !original.Status.Terminal() {
h.finishReplay(w, r, webhook, replayNotTerminal) redirectToEventLog(w, r, webhook, replayNotTerminal)
return return
} }
target, code := h.replayTarget(webhook.ID, original.TargetID) target, code := h.replayTarget(webhook.ID, original.TargetID)
if target == nil { if target == nil {
h.finishReplay(w, r, webhook, code) redirectToEventLog(w, r, webhook, code)
return return
} }
@@ -200,7 +165,7 @@ func (h *Handlers) queueReplay(
} }
if inFlight > 0 { if inFlight > 0 {
h.finishReplay(w, r, webhook, replayInFlight) redirectToEventLog(w, r, webhook, replayInFlight)
return return
} }
@@ -238,7 +203,7 @@ func (h *Handlers) queueReplay(
"delivery_id", task.DeliveryID, "delivery_id", task.DeliveryID,
) )
h.finishReplay(w, r, webhook, replayQueued) redirectToEventLog(w, r, webhook, replayQueued)
} }
// replayTarget loads the delivery's target as it stands now. // replayTarget loads the delivery's target as it stands now.
@@ -251,7 +216,7 @@ func (h *Handlers) queueReplay(
// with the returned code saying why. // with the returned code saying why.
func (h *Handlers) replayTarget( func (h *Handlers) replayTarget(
webhookID, targetID string, webhookID, targetID string,
) (*database.Target, replayOutcomeCode) { ) (*database.Target, noticeCode) {
var target database.Target var target database.Target
err := h.db.DB().Unscoped().Where( err := h.db.DB().Unscoped().Where(
@@ -361,17 +326,16 @@ func replayBody(body string) *string {
return &body return &body
} }
// finishReplay redirects back to the event log the replay was // redirectToEventLog redirects a replay or resubmit back to the event
// triggered from, carrying the outcome code the page turns into a // log it was triggered from, carrying the outcome as its notice and
// banner and the page number the form submitted. // the page number the form submitted.
func (h *Handlers) finishReplay( func redirectToEventLog(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
webhook database.Webhook, webhook database.Webhook,
code replayOutcomeCode, code noticeCode,
) { ) {
dest := "/hook/" + webhook.ID + "/events?" + dest := withNotice("/hook/"+webhook.ID+"/events", code)
replayOutcomeParam + "=" + string(code)
// The page is read from the form rather than the query string: // The page is read from the form rather than the query string:
// this is a POST, and its query string is what logs and Referer // this is a POST, and its query string is what logs and Referer
+8 -8
View File
@@ -212,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?replay=queued", "/hook/"+wh.ID+"/events?notice=replay-queued",
w.Header().Get("Location"), w.Header().Get("Location"),
) )
@@ -362,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?replay=target-deleted", "/hook/"+wh.ID+"/events?notice=replay-target-deleted",
w.Header().Get("Location"), w.Header().Get("Location"),
) )
@@ -390,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, missing.Code) require.Equal(t, http.StatusSeeOther, missing.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?replay=target-missing", "/hook/"+wh.ID+"/events?notice=replay-target-missing",
missing.Header().Get("Location"), missing.Header().Get("Location"),
) )
} }
@@ -431,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, first.Code) require.Equal(t, http.StatusSeeOther, first.Code)
require.Equal( require.Equal(
t, t,
"/hook/"+wh.ID+"/events?replay=queued", "/hook/"+wh.ID+"/events?notice=replay-queued",
first.Header().Get("Location"), first.Header().Get("Location"),
) )
@@ -439,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, second.Code) require.Equal(t, http.StatusSeeOther, second.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?replay=in-flight", "/hook/"+wh.ID+"/events?notice=replay-in-flight",
second.Header().Get("Location"), second.Header().Get("Location"),
) )
@@ -465,7 +465,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, pending.Code) require.Equal(t, http.StatusSeeOther, pending.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?replay=not-terminal", "/hook/"+wh.ID+"/events?notice=replay-not-terminal",
pending.Header().Get("Location"), pending.Header().Get("Location"),
) )
} }
@@ -509,7 +509,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
assert.Contains(t, body, ">Replay<") assert.Contains(t, body, ">Replay<")
refused := renderSourceLogsPageWithQuery( refused := renderSourceLogsPageWithQuery(
t, h, sess, wh.ID, "?replay=target-deleted", t, h, sess, wh.ID, "?notice=replay-target-deleted",
) )
assert.Contains(t, refused, "alert-error") assert.Contains(t, refused, "alert-error")
@@ -517,7 +517,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
// An outcome code nobody issued renders no banner at all. // An outcome code nobody issued renders no banner at all.
unknown := renderSourceLogsPageWithQuery( unknown := renderSourceLogsPageWithQuery(
t, h, sess, wh.ID, "?replay=made-up", t, h, sess, wh.ID, "?notice=made-up",
) )
assert.NotContains(t, unknown, "alert-error") assert.NotContains(t, unknown, "alert-error")
+5 -54
View File
@@ -3,7 +3,6 @@ package handlers
import ( import (
"errors" "errors"
"net/http" "net/http"
"strconv"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/google/uuid" "github.com/google/uuid"
@@ -11,43 +10,19 @@ import (
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// resubmitOutcomeParam is the query parameter the resubmit POST // The outcomes of a resubmit POST, as the notice codes its redirect
// redirects with and the event log page reads its banner from. // carries. noticeFor holds the line each one shows.
const resubmitOutcomeParam = "resubmit"
// resubmitOutcomeCode is the outcome of a resubmit POST. The redirect
// carries one of these fixed codes rather than a message, so nothing a
// client submits can reach the rendered page through it.
type resubmitOutcomeCode string
const ( const (
// resubmitQueued reports that a new event was stored and its // resubmitQueued reports that a new event was stored and its
// deliveries handed to the delivery engine. // deliveries handed to the delivery engine.
resubmitQueued resubmitOutcomeCode = "queued" resubmitQueued noticeCode = "resubmit-queued"
// resubmitNoTargets reports a source with no active targets. The // resubmitNoTargets reports a source with no active targets. The
// new event is stored either way, exactly as a received event // new event is stored either way, exactly as a received event
// with no targets is. // with no targets is.
resubmitNoTargets resubmitOutcomeCode = "no-targets" resubmitNoTargets noticeCode = "resubmit-no-targets"
) )
// resubmitOutcome returns the banner the event log page shows for an
// outcome code, and whether the resubmit was queued. An unrecognised
// code yields no banner.
func resubmitOutcome(code string) (string, bool) {
switch resubmitOutcomeCode(code) {
case resubmitQueued:
return "Resubmitted: a new event was created from the stored " +
"one and queued to every active target.", true
case resubmitNoTargets:
return "Resubmitted: a new event was created, but this " +
"source has no active targets, so nothing was queued.",
true
default:
return "", false
}
}
// resubmitSource is the stored event a resubmit copies. Its body is // resubmitSource is the stored event a resubmit copies. Its body is
// read as bytes rather than as a string so the copy is byte-identical // read as bytes rather than as a string so the copy is byte-identical
// to what was received, whatever the payload's encoding. // to what was received, whatever the payload's encoding.
@@ -245,29 +220,5 @@ func (h *Handlers) queueResubmit(
code = resubmitNoTargets code = resubmitNoTargets
} }
h.finishResubmit(w, r, webhook, code) redirectToEventLog(w, r, webhook, code)
}
// finishResubmit redirects back to the event log the resubmit was
// triggered from, carrying the outcome code the page turns into a
// banner and the page number the form submitted.
func (h *Handlers) finishResubmit(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
code resubmitOutcomeCode,
) {
dest := "/hook/" + webhook.ID + "/events?" +
resubmitOutcomeParam + "=" + string(code)
// The page is read from the form rather than the query string:
// this is a POST, and its query string is what logs and Referer
// headers record.
if page := pageOrFirst(
r.PostFormValue("page"),
); page > 1 {
dest += "&page=" + strconv.Itoa(page)
}
http.Redirect(w, r, dest, http.StatusSeeOther)
} }
+4 -4
View File
@@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?resubmit=queued", "/hook/"+wh.ID+"/events?notice=resubmit-queued",
w.Header().Get("Location"), w.Header().Get("Location"),
) )
@@ -282,7 +282,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?resubmit=queued", "/hook/"+wh.ID+"/events?notice=resubmit-queued",
w.Header().Get("Location"), w.Header().Get("Location"),
"a resubmit must not be refused while an earlier "+ "a resubmit must not be refused while an earlier "+
"one is in flight", "one is in flight",
@@ -436,7 +436,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?resubmit=queued", "/hook/"+wh.ID+"/events?notice=resubmit-queued",
w.Header().Get("Location"), w.Header().Get("Location"),
"an inactive target is skipped, not an error", "an inactive target is skipped, not an error",
) )
@@ -482,7 +482,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?resubmit=no-targets", "/hook/"+wh.ID+"/events?notice=resubmit-no-targets",
w.Header().Get("Location"), w.Header().Get("Location"),
) )
+60 -18
View File
@@ -10,9 +10,12 @@ import (
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"sync"
"sync/atomic" "sync/atomic"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
@@ -56,6 +59,7 @@ type HandlersParams struct {
Logger *logger.Logger Logger *logger.Logger
Globals *globals.Globals Globals *globals.Globals
Config *config.Config
Database *database.Database Database *database.Database
WebhookDBMgr *database.WebhookDBManager WebhookDBMgr *database.WebhookDBManager
Healthcheck *healthcheck.Healthcheck Healthcheck *healthcheck.Healthcheck
@@ -64,6 +68,8 @@ type HandlersParams struct {
Notifier delivery.Notifier Notifier delivery.Notifier
Archives delivery.Archives Archives delivery.Archives
SSRFGuard *delivery.Guard SSRFGuard *delivery.Guard
Metrics *metrics.Set
Registry *prometheus.Registry
} }
// Handlers provides HTTP handler methods for all application // Handlers provides HTTP handler methods for all application
@@ -86,6 +92,14 @@ type Handlers struct {
// is one delivery will actually attempt. // is one delivery will actually attempt.
ssrf *delivery.Guard ssrf *delivery.Guard
// renameMu makes the webhook edit, the target edit and target
// creation run one at a time, each held from loading the stored
// names through the archive rename, the save and any move back.
// Interleaved, one could rename an archive between another's
// rename and save, leaving the file named for one edit and the
// stored names from the other.
renameMu sync.Mutex
// dummyVerifications counts the equivalent-cost verifications // dummyVerifications counts the equivalent-cost verifications
// charged for usernames that do not exist. It exists so a test // charged for usernames that do not exist. It exists so a test
// can prove that path runs without measuring wall-clock time. // can prove that path runs without measuring wall-clock time.
@@ -94,10 +108,10 @@ type Handlers struct {
// parsePageTemplate parses a page-specific template set from the // parsePageTemplate parses a page-specific template set from the
// embedded FS. Each page template is combined with the shared // embedded FS. Each page template is combined with the shared
// base, htmlheader, and navbar templates, and with any further files // base, htmlheader, navbar and notice templates, and with any further
// the page includes. The page file must be listed first so that its // files the page includes. The page file must be listed first so that
// root action ({{template "base" .}}) becomes the template set's entry // its root action ({{template "base" .}}) becomes the template set's
// point. // entry point.
func parsePageTemplate( func parsePageTemplate(
pageFile string, included ...string, pageFile string, included ...string,
) *template.Template { ) *template.Template {
@@ -106,6 +120,7 @@ func parsePageTemplate(
"base.html", "base.html",
"htmlheader.html", "htmlheader.html",
"navbar.html", "navbar.html",
"notice.html",
}, included...) }, included...)
return template.Must( return template.Must(
@@ -129,13 +144,14 @@ func New(
s.mw = params.Middleware s.mw = params.Middleware
s.notifier = params.Notifier s.notifier = params.Notifier
s.archives = params.Archives s.archives = params.Archives
s.mtr = metrics.Default() s.mtr = params.Metrics
s.ssrf = params.SSRFGuard s.ssrf = params.SSRFGuard
// Parse all page templates once at startup // Parse all page templates once at startup
s.templates = map[string]*template.Template{ s.templates = map[string]*template.Template{
"login.html": parsePageTemplate("login.html"), "login.html": parsePageTemplate("login.html"),
"profile.html": parsePageTemplate("profile.html"), "profile.html": parsePageTemplate("profile.html"),
"settings.html": parsePageTemplate("settings.html"),
"sources_list.html": parsePageTemplate("sources_list.html"), "sources_list.html": parsePageTemplate("sources_list.html"),
"sources_new.html": parsePageTemplate("sources_new.html"), "sources_new.html": parsePageTemplate("sources_new.html"),
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"), "source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
@@ -206,11 +222,13 @@ func (s *Handlers) renderError(
// served outside the routes where NoCache runs. // served outside the routes where NoCache runs.
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
// No notice: one would say an action worked above a page saying
// the request failed.
data := s.pageData(r, map[string]any{ data := s.pageData(r, map[string]any{
"Status": status, "Status": status,
"StatusText": http.StatusText(status), "StatusText": http.StatusText(status),
"Message": errorPageText(status), "Message": errorPageText(status),
}) }, nil)
var buf bytes.Buffer var buf bytes.Buffer
@@ -264,6 +282,7 @@ type templateDataWrapper struct {
User *UserInfo User *UserInfo
CSRFToken string CSRFToken string
Version string Version string
Notice *notice
Data any Data any
} }
@@ -290,12 +309,26 @@ func (s *Handlers) getUserInfo(
} }
// renderTemplate renders a pre-parsed template with common // renderTemplate renders a pre-parsed template with common
// data // data and answers 200.
func (s *Handlers) renderTemplate( func (s *Handlers) renderTemplate(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
pageTemplate string, pageTemplate string,
data any, data any,
) {
s.renderTemplateStatus(w, r, pageTemplate, data, http.StatusOK)
}
// renderTemplateStatus is renderTemplate answering with status, for a
// form shown again with an error. Call it instead of WriteHeader
// followed by renderTemplate: the status is written only once the page
// has rendered, so a failed render can still answer 500.
func (s *Handlers) renderTemplateStatus(
w http.ResponseWriter,
r *http.Request,
pageTemplate string,
data any,
status int,
) { ) {
tmpl, ok := s.templates[pageTemplate] tmpl, ok := s.templates[pageTemplate]
if !ok { if !ok {
@@ -308,12 +341,17 @@ func (s *Handlers) renderTemplate(
return return
} }
s.executeTemplate(w, r, tmpl, s.pageData(r, data)) s.executeTemplate(
w, r, tmpl, s.pageData(r, data, noticeFor(r)), status,
)
} }
// pageData adds the fields the shared layout renders to a page's own // pageData adds the fields the shared layout renders to a page's own
// data. // data. The layout shows the notice, when there is one, above the
func (s *Handlers) pageData(r *http.Request, data any) any { // page.
func (s *Handlers) pageData(
r *http.Request, data any, pageNotice *notice,
) any {
userInfo := s.getUserInfo(r) userInfo := s.getUserInfo(r)
csrfToken := middleware.CSRFToken(r) csrfToken := middleware.CSRFToken(r)
@@ -327,6 +365,7 @@ func (s *Handlers) pageData(r *http.Request, data any) any {
m["User"] = userInfo m["User"] = userInfo
m["CSRFToken"] = csrfToken m["CSRFToken"] = csrfToken
m["Version"] = version m["Version"] = version
m["Notice"] = pageNotice
return m return m
} }
@@ -335,23 +374,25 @@ func (s *Handlers) pageData(r *http.Request, data any) any {
User: userInfo, User: userInfo,
CSRFToken: csrfToken, CSRFToken: csrfToken,
Version: version, Version: version,
Notice: pageNotice,
Data: data, Data: data,
} }
} }
// executeTemplate renders the template into a buffer and writes to // executeTemplate renders the template into a buffer and writes status
// the response only once rendering has fully succeeded. Executing // and the page to the response only once rendering has fully
// straight into the ResponseWriter commits a partial body and a 200 // succeeded. Executing straight into the ResponseWriter commits a
// status before a mid-render error can be reported, leaving no way // partial body and the status before a mid-render error can be
// to serve a 500. Buffering makes a page's rendered size resident // reported, leaving no way to serve a 500. Buffering makes a page's
// memory per concurrent viewer, so every page owes it a bound: the // rendered size resident memory per concurrent viewer, so every page
// event log caps each stored body at maxRenderedBodyBytes for exactly // owes it a bound: the event log caps each stored body at
// this reason. // maxRenderedBodyBytes for exactly this reason.
func (s *Handlers) executeTemplate( func (s *Handlers) executeTemplate(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
tmpl *template.Template, tmpl *template.Template,
data any, data any,
status int,
) { ) {
var buf bytes.Buffer var buf bytes.Buffer
@@ -366,6 +407,7 @@ func (s *Handlers) executeTemplate(
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, err = buf.WriteTo(w) _, err = buf.WriteTo(w)
if err != nil { if err != nil {
+63 -12
View File
@@ -21,6 +21,7 @@ import (
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
) )
@@ -55,13 +56,16 @@ func (n *recordingNotifier) Tasks() []delivery.Task {
// recordingArchives is a delivery.Archives that records what it // recordingArchives is a delivery.Archives that records what it
// was asked to do, so a test can prove that a deletion or rename // was asked to do, so a test can prove that a deletion or rename
// path reached the delivery engine. After FailRenames, every // path reached the delivery engine. After FailRenames, every
// rename fails with the given error. // rename of that target fails with the given error. After
// BlockNextRename, the next rename is recorded and then waits.
type recordingArchives struct { type recordingArchives struct {
mu sync.Mutex mu sync.Mutex
evicted []string evicted []string
evictedTargets []string evictedTargets []string
renames []archiveRename renames []archiveRename
renameErr error renameErrs map[string]error
entered chan struct{}
release chan struct{}
} }
// errInjectedRename is the failure a test hands FailRenames. // errInjectedRename is the failure a test hands FailRenames.
@@ -98,23 +102,50 @@ func (r *recordingArchives) Rename(
targetID, webhookName, targetName string, targetID, webhookName, targetName string,
) error { ) error {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock()
r.renames = append(r.renames, archiveRename{ r.renames = append(r.renames, archiveRename{
TargetID: targetID, TargetID: targetID,
WebhookName: webhookName, WebhookName: webhookName,
TargetName: targetName, TargetName: targetName,
}) })
err := r.renameErrs[targetID]
entered, release := r.entered, r.release
r.entered, r.release = nil, nil
return r.renameErr r.mu.Unlock()
if entered != nil {
close(entered)
<-release
}
return err
} }
// FailRenames makes every later rename fail with err. // BlockNextRename makes the next rename, once recorded, wait until
func (r *recordingArchives) FailRenames(err error) { // the returned release is called. The returned channel is closed
// when that rename starts waiting.
func (r *recordingArchives) BlockNextRename() (<-chan struct{}, func()) {
entered := make(chan struct{})
release := make(chan struct{})
r.mu.Lock()
r.entered, r.release = entered, release
r.mu.Unlock()
return entered, func() { close(release) }
}
// FailRenames makes every later rename of targetID fail with err.
func (r *recordingArchives) FailRenames(targetID string, err error) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
r.renameErr = err if r.renameErrs == nil {
r.renameErrs = map[string]error{}
}
r.renameErrs[targetID] = err
} }
// Evicted returns a copy of the recorded webhook ids. // Evicted returns a copy of the recorded webhook ids.
@@ -150,22 +181,40 @@ func (r *recordingArchives) Renames() []archiveRename {
return out return out
} }
// newTestApp returns an app whose RequireStart fails the test when
// starting takes longer than fx's default start timeout of 15s. That
// limit catches a start that hangs, not a busy host: measured with make
// test on 2026-10-02 at host load 58-69 on 48 cores, the slowest of this
// package's starts took 0.49s.
func newTestApp( func newTestApp(
t *testing.T, t *testing.T,
targets ...any, targets ...any,
) *fxtest.App { ) *fxtest.App {
t.Helper() t.Helper()
return newTestAppWithConfig(
t, &config.Config{DataDir: t.TempDir()}, targets...,
)
}
// newTestAppWithConfig is newTestApp over a caller-supplied Config.
func newTestAppWithConfig(
t *testing.T,
cfg *config.Config,
targets ...any,
) *fxtest.App {
t.Helper()
return fxtest.New( return fxtest.New(
t, t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned.
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
func() *config.Config { func() *config.Config { return cfg },
return &config.Config{
DataDir: t.TempDir(),
}
},
database.New, database.New,
database.NewWebhookDBManager, database.NewWebhookDBManager,
healthcheck.New, healthcheck.New,
@@ -182,6 +231,8 @@ func newTestApp(
func(r *recordingArchives) delivery.Archives { func(r *recordingArchives) delivery.Archives {
return r return r
}, },
metrics.NewRegistry,
metrics.New,
middleware.New, middleware.New,
delivery.NewGuard, delivery.NewGuard,
handlers.New, handlers.New,
+21
View File
@@ -0,0 +1,21 @@
package handlers
import (
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// HandleMetrics returns the Prometheus scrape handler for the
// registry built by metrics.NewRegistry, which the HTTP, delivery, Go
// runtime and process collectors register on. It is what
// promhttp.Handler builds for the global default registry, including
// the promhttp_metric_handler_* series that count scrapes, pointed at
// that registry instead.
func (s *Handlers) HandleMetrics() http.HandlerFunc {
reg := s.params.Registry
return promhttp.InstrumentMetricHandler(
reg, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}),
).ServeHTTP
}
+109
View File
@@ -0,0 +1,109 @@
package handlers
import "net/http"
// noticeParam is the query parameter an action's redirect carries its
// notice code in.
const noticeParam = "notice"
// noticeCode names one of the fixed lines noticeFor knows. An action
// redirects with the code rather than the line, so nothing a client
// puts in the URL reaches the page: a code noticeFor does not know
// shows nothing.
type noticeCode string
// The codes of the actions on the webhook pages and of signing out.
// Replay's codes, with the reasons a replay can be refused, and
// resubmit's codes are defined beside those actions.
const (
webhookCreated noticeCode = "webhook-created"
webhookSaved noticeCode = "webhook-saved"
webhookDeleted noticeCode = "webhook-deleted"
entrypointAdded noticeCode = "entrypoint-added"
entrypointDeleted noticeCode = "entrypoint-deleted"
entrypointActivated noticeCode = "entrypoint-activated"
entrypointDeactivated noticeCode = "entrypoint-deactivated"
targetAdded noticeCode = "target-added"
targetSaved noticeCode = "target-saved"
targetDeleted noticeCode = "target-deleted"
targetActivated noticeCode = "target-activated"
targetDeactivated noticeCode = "target-deactivated"
signedOut noticeCode = "signed-out"
)
// notice is the line templates/notice.html shows above a page to say
// what an action did.
type notice struct {
Text string
// Failed shows the line as an error: the action was refused.
Failed bool
}
// noticeFor returns the notice the request's URL names, or nil when it
// names none or an unknown code.
func noticeFor(r *http.Request) *notice {
n, ok := map[noticeCode]notice{
webhookCreated: {Text: "Webhook created."},
webhookSaved: {Text: "Webhook saved."},
webhookDeleted: {Text: "Webhook deleted."},
entrypointAdded: {Text: "Entrypoint added."},
entrypointDeleted: {Text: "Entrypoint deleted."},
entrypointActivated: {Text: "Entrypoint activated."},
entrypointDeactivated: {Text: "Entrypoint deactivated."},
targetAdded: {Text: "Target added."},
targetSaved: {Text: "Target saved."},
targetDeleted: {Text: "Target deleted."},
targetActivated: {Text: "Target activated."},
targetDeactivated: {Text: "Target deactivated."},
signedOut: {Text: "Signed out."},
replayQueued: {
Text: "Replay queued: a new delivery was created " +
"against the target's current configuration.",
},
replayTargetDeleted: {
Text: "Not replayed: the target this delivery was for " +
"has been deleted. Recreate the target, then replay.",
Failed: true,
},
replayTargetMissing: {
Text: "Not replayed: the target this delivery was for " +
"no longer exists.",
Failed: true,
},
replayTargetInactive: {
Text: "Not replayed: the target this delivery was for " +
"is deactivated. Activate it, then replay.",
Failed: true,
},
replayNotTerminal: {
Text: "Not replayed: this delivery has not finished yet.",
Failed: true,
},
replayInFlight: {
Text: "Not replayed: a delivery of this event to this " +
"target is already in flight.",
Failed: true,
},
resubmitQueued: {
Text: "Resubmitted: a new event was created from the " +
"stored one and queued to every active target.",
},
resubmitNoTargets: {
Text: "Resubmitted: a new event was created, but this " +
"source has no active targets, so nothing was queued.",
},
}[noticeCode(r.URL.Query().Get(noticeParam))]
if !ok {
return nil
}
return &n
}
// withNotice returns path with code added as its notice.
func withNotice(path string, code noticeCode) string {
return path + "?" + noticeParam + "=" + string(code)
}
+128
View File
@@ -0,0 +1,128 @@
package handlers
import (
"net/http"
"net/netip"
"strconv"
"strings"
"sneak.berlin/go/webhooker/internal/config"
)
// notSet is what the Settings page shows for a value that is empty.
const notSet = "not set"
// settingRow is one line of the Settings page: an environment
// variable, what it controls, and the value the server loaded for it.
type settingRow struct {
Name string
Description string
Value string
}
// HandleSettings returns a handler for the read-only Settings page,
// which lists the configuration the server started with.
func (h *Handlers) HandleSettings() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.renderTemplate(w, r, "settings.html", map[string]any{
"Settings": settingRows(h.params.Config),
})
}
}
// settingRows lists every field of cfg under the environment variable
// it is read from, with the description the README's configuration
// table gives it (less its pointers to other README sections), in the
// table's order. METRICS_PASSWORD and SENTRY_DSN are credentials, so
// their values never reach the page: only whether they are set.
func settingRows(cfg *config.Config) []settingRow {
metricsUsername := cfg.MetricsUsername
if metricsUsername == "" {
metricsUsername = notSet
}
return []settingRow{
{"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment},
{"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)},
{
"BIND_ADDRESS",
"IP address the HTTP listener binds. Loopback by default, " +
"so the cleartext listener is not published on every " +
"interface. The Docker image ships 0.0.0.0 instead",
cfg.BindAddress,
},
{"DATA_DIR", "Directory for all SQLite databases", cfg.DataDir},
{"DEBUG", "Enable debug logging", strconv.FormatBool(cfg.Debug)},
{
"METRICS_USERNAME",
"Basic auth username for /metrics. Must be set together " +
"with METRICS_PASSWORD; one without the other fails " +
"startup",
metricsUsername,
},
{
"METRICS_PASSWORD",
"Basic auth password for /metrics. Must be set together " +
"with METRICS_USERNAME; one without the other fails " +
"startup",
setOrNotSet(cfg.MetricsPassword),
},
{
"SENTRY_DSN",
"Sentry error reporting DSN. Unset leaves error reporting " +
"off; a value the Sentry SDK cannot parse fails startup " +
"rather than serving with reporting silently off",
setOrNotSet(cfg.SentryDSN),
},
{
"RETENTION_SWEEP_INTERVAL",
"How often the retention reaper and archive sweeper run " +
"(Go duration, must be positive)",
cfg.RetentionSweepInterval.String(),
},
{
"SESSION_IDLE_TIMEOUT",
"Idle session timeout (Go duration)",
cfg.SessionIdleTimeout.String(),
},
{
"RECEIVER_RATE_LIMIT",
"Receiver requests/minute per IP per entrypoint " +
"(10x that per IP across the route)",
strconv.Itoa(cfg.ReceiverRateLimit),
},
{
"TRUSTED_PROXIES",
"CIDRs whose forwarded headers are trusted. A set value " +
"replaces the default. If any client can reach webhooker, " +
"or the proxy in front of it, from an RFC 1918 source " +
"address, set it to the proxy's address alone",
cidrList(cfg.TrustedProxies),
},
{
"ALLOWED_EGRESS_CIDRS",
"CIDRs that delivery targets may reach despite the " +
"SSRF blocklist",
cidrList(cfg.AllowedEgressCIDRs),
},
}
}
// setOrNotSet is how the Settings page shows a credential: whether it
// has a value, never the value itself.
func setOrNotSet(value string) string {
if value == "" {
return notSet
}
return "set"
}
// cidrList renders a CIDR list setting for the Settings page.
func cidrList(prefixes []netip.Prefix) string {
if len(prefixes) == 0 {
return "none"
}
return strings.Join(config.PrefixStrings(prefixes), ", ")
}
+148
View File
@@ -0,0 +1,148 @@
package handlers_test
import (
"context"
"html"
"net/http"
"net/http/httptest"
"net/netip"
"regexp"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
// settingsShown renders the Settings page over cfg as a logged-in user
// and returns the value it shows for each variable name, plus the
// whole page.
func settingsShown(
t *testing.T, cfg *config.Config,
) (map[string]string, string) {
t.Helper()
var h *handlers.Handlers
var sess *session.Session
app := newTestAppWithConfig(t, cfg, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/settings", nil,
)
for _, c := range authenticatedCookies(t, sess, "id", "admin") {
req.AddCookie(c)
}
w := httptest.NewRecorder()
h.HandleSettings().ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
body := w.Body.String()
row := regexp.MustCompile(
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
)
shown := map[string]string{}
for _, match := range row.FindAllStringSubmatch(body, -1) {
shown[match[1]] = html.UnescapeString(match[2])
}
return shown, body
}
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
t.Parallel()
// Each of METRICS_USERNAME, METRICS_PASSWORD and SENTRY_DSN is the
// only one of the three set in one of the content tests, so each
// row is checked against its own field.
cfg := &config.Config{
DataDir: t.TempDir(),
Debug: true,
Environment: config.EnvironmentDev,
MetricsUsername: "scraper",
MetricsPassword: "",
Port: 9123,
SentryDSN: "",
BindAddress: "192.0.2.10",
RetentionSweepInterval: 17 * time.Minute,
SessionIdleTimeout: 3 * time.Hour,
ReceiverRateLimit: 77,
TrustedProxies: []netip.Prefix{
netip.MustParsePrefix("10.1.0.0/16"),
},
AllowedEgressCIDRs: []netip.Prefix{
netip.MustParsePrefix("192.168.5.0/24"),
netip.MustParsePrefix("fd00::/8"),
},
}
shown, body := settingsShown(t, cfg)
assert.Equal(t, map[string]string{
"WEBHOOKER_ENVIRONMENT": "dev",
"PORT": "9123",
"BIND_ADDRESS": "192.0.2.10",
"DATA_DIR": cfg.DataDir,
"DEBUG": "true",
"METRICS_USERNAME": "scraper",
"METRICS_PASSWORD": "not set",
"SENTRY_DSN": "not set",
"RETENTION_SWEEP_INTERVAL": "17m0s",
"SESSION_IDLE_TIMEOUT": "3h0m0s",
"RECEIVER_RATE_LIMIT": "77",
"TRUSTED_PROXIES": "10.1.0.0/16",
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
}, shown)
assert.Contains(
t, body, `href="/settings"`,
"the navigation bar links to the page",
)
}
func TestSettingsPageShowsUnsetValues(t *testing.T) {
t.Parallel()
const metricsPassword = "metrics-password-1f9a"
shown, body := settingsShown(t, &config.Config{
DataDir: t.TempDir(),
MetricsPassword: metricsPassword,
})
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
assert.Equal(t, "set", shown["METRICS_PASSWORD"])
assert.Equal(t, "not set", shown["SENTRY_DSN"])
assert.NotContains(t, body, metricsPassword)
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
}
func TestSettingsPageShowsSentryDSNOnlyAsSet(t *testing.T) {
t.Parallel()
const (
sentryKey = "dsnkey7c2e"
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
)
shown, body := settingsShown(t, &config.Config{
DataDir: t.TempDir(),
SentryDSN: sentryDSN,
})
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
assert.Equal(t, "set", shown["SENTRY_DSN"])
assert.NotContains(t, body, sentryKey)
}
+29 -1
View File
@@ -80,6 +80,10 @@ func seedTarget(
// from a delete statement. // from a delete statement.
var errInjectedDelete = errors.New("injected delete failure") var errInjectedDelete = errors.New("injected delete failure")
// errInjectedSave is the failure failSaveOnTable reports from a
// save of an existing row.
var errInjectedSave = errors.New("injected save failure")
// seedEntrypoint inserts an entrypoint for a webhook. // seedEntrypoint inserts an entrypoint for a webhook.
func seedEntrypoint( func seedEntrypoint(
t *testing.T, t *testing.T,
@@ -147,6 +151,28 @@ func failDeleteOnTable(
) )
} }
// failSaveOnTable is failDeleteOnTable for saves: every update of
// an existing row in the named table fails.
func failSaveOnTable(
t *testing.T,
db *database.Database,
table string,
) {
t.Helper()
require.NoError(t, db.DB().Callback().Update().
Before("gorm:update").
Register(
"test:fail_save_"+table,
func(tx *gorm.DB) {
if tx.Statement.Table == table {
_ = tx.AddError(errInjectedSave)
}
},
),
)
}
// archivePathFor returns the archive database path the // archivePathFor returns the archive database path the
// delivery engine would use for a database target: beside the // delivery engine would use for a database target: beside the
// webhook's event database in the data directory. // webhook's event database in the data directory.
@@ -414,7 +440,9 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
h.HandleSourceDelete().ServeHTTP(w, req) h.HandleSourceDelete().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/hooks", w.Header().Get("Location")) assert.Equal(
t, "/hooks?notice=webhook-deleted", w.Header().Get("Location"),
)
assert.Equal( assert.Equal(
t, int64(0), t, int64(0),
+34
View File
@@ -241,3 +241,37 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
assert.Contains(t, body, "(unavailable)") assert.Contains(t, body, "(unavailable)")
assert.NotContains(t, body, "beak") assert.NotContains(t, body, "beak")
} }
// TestHandleSourceDetail_FitsWideAndNarrowWindows pins the webhook
// page's maximum width at 108rem (1728 px), half again the 72rem of
// max-w-6xl that the webhook list and the event log use, so an
// entrypoint URL fits on one line in a 1920-pixel window; and the
// wrapping of its title row, so the buttons beside the title do not
// push a phone-width window into scrolling sideways.
func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
)
app := newTestApp(t, &h, &sess, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(
t, body,
`<div class="mx-auto px-6 py-8" style="max-width: 108rem"`,
)
assert.Contains(
t, body,
`<div class="flex flex-wrap justify-between items-center gap-2 mt-2">`,
)
}
+467
View File
@@ -0,0 +1,467 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session"
)
// failedHighlight is how the list marks a number of failed deliveries
// that is not zero.
const failedHighlight = `class="font-medium text-red-600"`
// listWebhook adds a webhook with the given name, owned by the test
// user.
func listWebhook(
t *testing.T, db *database.Database, name string,
) *database.Webhook {
t.Helper()
wh := &database.Webhook{UserID: deleteTestUserID, Name: name}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
return wh
}
// addEntrypoints adds the given number of entrypoints, all active or
// all inactive, to a webhook and returns their paths.
func addEntrypoints(
t *testing.T, db *database.Database, webhookID string,
count int, active bool,
) []string {
t.Helper()
paths := make([]string, count)
for i := range paths {
paths[i] = statsEntrypoint(t, db, webhookID, active)
}
return paths
}
// addTargets adds the given number of targets, all active or all
// inactive, to a webhook and returns them.
func addTargets(
t *testing.T, db *database.Database, webhookID string,
count int, active bool,
) []*database.Target {
t.Helper()
targets := make([]*database.Target, count)
for i := range targets {
targets[i] = seedTarget(t, db, webhookID, database.TargetTypeLog)
require.NoError(t, db.DB().Model(targets[i]).
Update("active", active).Error)
}
return targets
}
// renderWebhookList runs the real webhook list handler as the test user
// and returns the rendered page.
func renderWebhookList(
t *testing.T, h *handlers.Handlers, sess *session.Session,
) string {
t.Helper()
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
w := httptest.NewRecorder()
h.HandleSourceList().ServeHTTP(
w, getRequest(t, "/hooks", cookies, nil),
)
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}
// listCard returns one webhook's entry in a rendered webhook list, its
// markup as rendered and its text with the markup taken out and each
// run of space made one space.
func listCard(t *testing.T, page, webhookID string) (string, string) {
t.Helper()
_, card, found := strings.Cut(page, `href="/hook/`+webhookID+`"`)
require.True(t, found, "the list has no entry for %s", webhookID)
card, _, _ = strings.Cut(card, "</a>")
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(card, " ")
return card, strings.Join(strings.Fields(text), " ")
}
// receiveEvents posts the given number of events to an entrypoint
// through the real receiver, and returns the webhook's event database
// and its events, oldest first.
func receiveEvents(
t *testing.T,
h *handlers.Handlers,
dbMgr *database.WebhookDBManager,
webhookID, path string,
count int,
) (*gorm.DB, []database.Event) {
t.Helper()
router := receiverRouter(h)
for range count {
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
}
webhookDB, err := dbMgr.GetDB(webhookID)
require.NoError(t, err)
events := listEvents(t, webhookDB)
require.Len(t, events, count)
return webhookDB, events
}
// seedFailingWebhook adds a webhook with six entrypoints, two of them
// inactive, and seven targets, five of them inactive. Four events reach
// its two active targets, arriving 31, 5, 4 and 3 hours ago, and its
// event totals row records the last one. Three deliveries failed in the
// last 24 hours, two to the first target and one to the second, one
// failed 30 hours ago, two were delivered, and two are still pending.
// It returns the webhook and when its last event arrived.
func seedFailingWebhook(
t *testing.T,
h *handlers.Handlers,
db *database.Database,
dbMgr *database.WebhookDBManager,
) (*database.Webhook, time.Time) {
t.Helper()
wh := listWebhook(t, db, "failing")
paths := addEntrypoints(t, db, wh.ID, 4, true)
addEntrypoints(t, db, wh.ID, 2, false)
active := addTargets(t, db, wh.ID, 2, true)
first, second := active[0], active[1]
addTargets(t, db, wh.ID, 5, false)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 4)
now := time.Now()
lastEventAt := now.Add(-3 * time.Hour)
statsAge(t, webhookDB, events[0].ID, now.Add(-31*time.Hour))
statsAge(t, webhookDB, events[1].ID, now.Add(-5*time.Hour))
statsAge(t, webhookDB, events[2].ID, now.Add(-4*time.Hour))
statsAge(t, webhookDB, events[3].ID, lastEventAt)
require.NoError(t, database.AddEventTotals(webhookDB,
database.EventTotals{LastEventAt: &lastEventAt}))
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[0].ID, second.ID),
database.DeliveryStatusDelivered, now.Add(-30*time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[1].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[2].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-time.Minute))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[2].ID, second.ID),
database.DeliveryStatusFailed, now.Add(-time.Minute))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[3].ID, second.ID),
database.DeliveryStatusDelivered, now.Add(-time.Minute))
return wh, lastEventAt
}
// seedHealthyWebhook adds a webhook with four entrypoints and two
// targets, all active, and three events, arriving 8, 7 and 6 hours ago
// and each delivered to both targets. Its event totals row records the
// last event. It returns the webhook and when its last event arrived.
func seedHealthyWebhook(
t *testing.T,
h *handlers.Handlers,
db *database.Database,
dbMgr *database.WebhookDBManager,
) (*database.Webhook, time.Time) {
t.Helper()
wh := listWebhook(t, db, "healthy")
paths := addEntrypoints(t, db, wh.ID, 4, true)
targets := addTargets(t, db, wh.ID, 2, true)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 3)
now := time.Now()
lastEventAt := now.Add(-6 * time.Hour)
statsAge(t, webhookDB, events[0].ID, now.Add(-8*time.Hour))
statsAge(t, webhookDB, events[1].ID, now.Add(-7*time.Hour))
statsAge(t, webhookDB, events[2].ID, lastEventAt)
require.NoError(t, database.AddEventTotals(webhookDB,
database.EventTotals{LastEventAt: &lastEventAt}))
for _, ev := range events {
for _, target := range targets {
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, ev.ID, target.ID),
database.DeliveryStatusDelivered, now)
}
}
return wh, lastEventAt
}
// lastEventText is how the list shows when the last event arrived.
func lastEventText(at time.Time) string {
return at.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, failingLastEvent := seedFailingWebhook(t, h, db, dbMgr)
healthy, healthyLastEvent := seedHealthyWebhook(t, h, db, dbMgr)
// Creating a webhook creates its event database.
fresh := listWebhook(t, db, "fresh")
require.NoError(t, dbMgr.CreateDB(fresh.ID))
addEntrypoints(t, db, fresh.ID, 2, true)
addTargets(t, db, fresh.ID, 3, true)
quiet := listWebhook(t, db, "quiet")
addEntrypoints(t, db, quiet.ID, 2, true)
addTargets(t, db, quiet.ID, 3, true)
page := renderWebhookList(t, h, sess)
card, text := listCard(t, page, failing.ID)
assert.Contains(t, text, "6 entrypoints, 2 inactive")
assert.Contains(t, text, "7 targets, 5 inactive")
assert.Contains(t, text, "4 events within retention")
assert.Contains(t, text, "Last event "+lastEventText(failingLastEvent))
assert.Contains(t, card,
failedHighlight+">3 failed deliveries in the last 24 hours<")
card, text = listCard(t, page, healthy.ID)
assert.Contains(t, text, "4 entrypoints")
assert.Contains(t, text, "2 targets")
assert.Contains(t, text, "3 events within retention")
assert.Contains(t, text, "Last event "+lastEventText(healthyLastEvent))
assert.Contains(t, text, "0 failed deliveries in the last 24 hours")
assert.NotContains(t, text, "inactive")
assert.NotContains(t, card, failedHighlight)
card, text = listCard(t, page, fresh.ID)
assert.Contains(t, text, "2 entrypoints")
assert.Contains(t, text, "3 targets")
assert.Contains(t, text, "0 events within retention")
assert.Contains(t, text, "No events yet")
assert.Contains(t, text, "0 failed deliveries in the last 24 hours")
assert.NotContains(t, card, failedHighlight)
card, text = listCard(t, page, quiet.ID)
assert.Contains(t, text, "2 entrypoints")
assert.Contains(t, text, "3 targets")
assert.Contains(t, text, "0 events within retention")
assert.Contains(t, text, "No events yet")
assert.Contains(t, text, "0 failed deliveries in the last 24 hours")
assert.NotContains(t, card, failedHighlight)
assert.False(t, dbMgr.DBExists(quiet.ID),
"showing the list must not create an event database")
}
// TestSourceList_CountsOnlyEventsWithinRetention checks that once
// retention has removed one of a webhook's three events, the list
// counts the two still stored.
func TestSourceList_CountsOnlyEventsWithinRetention(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "pruned", RetentionDays: 14,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
paths := addEntrypoints(t, db, wh.ID, 3, true)
addTargets(t, db, wh.ID, 4, true)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 3)
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-15*24*time.Hour))
statsPrune(t, db, dbMgr, log, webhookDB)
require.Len(t, listEvents(t, webhookDB), 2)
_, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
assert.Contains(t, text, "3 entrypoints")
assert.Contains(t, text, "4 targets")
assert.Contains(t, text, "2 events within retention")
}
// TestSourceList_LastEventSurvivesPruningEveryEvent checks that once
// retention has removed every event of a webhook, the list still shows
// when the last one arrived rather than "No events yet".
func TestSourceList_LastEventSurvivesPruningEveryEvent(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "emptied", RetentionDays: 1,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
paths := addEntrypoints(t, db, wh.ID, 2, true)
addTargets(t, db, wh.ID, 3, true)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 1)
lastEventAt := time.Now().Add(-50 * time.Hour)
statsAge(t, webhookDB, events[0].ID, lastEventAt)
require.NoError(t, database.AddEventTotals(webhookDB,
database.EventTotals{LastEventAt: &lastEventAt}))
statsPrune(t, db, dbMgr, log, webhookDB)
require.Empty(t, listEvents(t, webhookDB))
_, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
assert.Contains(t, text, "0 events within retention")
assert.Contains(t, text, "Last event "+lastEventText(lastEventAt))
assert.NotContains(t, text, "No events yet")
}
// TestSourceList_CountsOfOneInSingular checks that a webhook with one
// entrypoint, one target, one event within retention and one failed
// delivery in the last 24 hours has each written in the singular.
func TestSourceList_CountsOfOneInSingular(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)
wh := listWebhook(t, db, "single")
paths := addEntrypoints(t, db, wh.ID, 1, true)
targets := addTargets(t, db, wh.ID, 1, true)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, paths[0], 1)
now := time.Now()
lastEventAt := now.Add(-9 * time.Hour)
statsAge(t, webhookDB, events[0].ID, lastEventAt)
require.NoError(t, database.AddEventTotals(webhookDB,
database.EventTotals{LastEventAt: &lastEventAt}))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[0].ID, targets[0].ID),
database.DeliveryStatusFailed, now.Add(-time.Hour))
card, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
assert.Contains(t, card, ">1 entrypoint<")
assert.Contains(t, card, ">1 target<")
assert.Contains(t, card, ">1 event within retention<")
assert.Contains(t, text, "Last event "+lastEventText(lastEventAt))
assert.Contains(t, card,
failedHighlight+">1 failed delivery in the last 24 hours<")
}
// TestSourceList_UnreadableEventDatabase checks that a webhook whose
// event database cannot be read says so in its entry instead of
// showing zeros, and that the rest of the list is still shown.
func TestSourceList_UnreadableEventDatabase(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
broken := listWebhook(t, db, "broken")
addEntrypoints(t, db, broken.ID, 2, true)
addTargets(t, db, broken.ID, 3, true)
brokenDB, err := dbMgr.GetDB(broken.ID)
require.NoError(t, err)
require.NoError(t,
brokenDB.Migrator().DropTable(&database.EventTotals{}))
quiet := listWebhook(t, db, "quiet")
addEntrypoints(t, db, quiet.ID, 2, true)
addTargets(t, db, quiet.ID, 3, true)
page := renderWebhookList(t, h, sess)
_, text := listCard(t, page, broken.ID)
assert.Contains(t, text, "2 entrypoints")
assert.Contains(t, text, "3 targets")
assert.Contains(t, text, "The event figures could not be read.")
assert.NotContains(t, text, "events")
assert.NotContains(t, text, "failed")
_, text = listCard(t, page, quiet.ID)
assert.Contains(t, text, "No events yet")
}
+242 -139
View File
@@ -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,79 +22,70 @@ 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.
var errMissingURL = errors.New("missing URL") var errMissingURL = errors.New("missing URL")
// errInvalidRetention signals a retention_days form value that is not // parseRetentionDays interprets a retention_days form value. It
// a non-negative whole number. // returns the number of days, or, for a value it refuses, the message
var errInvalidRetention = errors.New("invalid retention days") // the create and edit forms show; the message is empty when the value
// is accepted.
// errRetentionTooLarge signals a retention_days form value that is a
// whole number but larger than the reaper's cutoff arithmetic can
// represent. It is distinguished from errInvalidRetention so the form
// can tell the user the actual ceiling instead of implying their input
// was not a number.
var errRetentionTooLarge = errors.New("retention days out of range")
// retentionErrorMessage returns the message the create and edit forms
// show the user for a rejected retention_days value. Any error other
// than errRetentionTooLarge falls back to the generic wording, so an
// unrecognised parse failure still produces a sensible 400 rather than
// an empty alert.
func retentionErrorMessage(err error) string {
if errors.Is(err, errRetentionTooLarge) {
return "Retention must be at most " +
strconv.Itoa(database.MaxFiniteRetentionDays) +
" days, or 0 to retain events forever."
}
return "Retention must be a whole number of days, or 0 to " +
"retain events forever."
}
// parseRetentionDays interprets a retention_days form value.
// //
// An empty value yields fallback, which lets the create path apply the // An empty value yields fallback, which lets the create path apply the
// default and the edit path leave the stored value unchanged. A value // default and the edit path leave the stored value unchanged. A value
// of 0 is returned as 0 and is rewritten to the retain-forever // of 0 is returned as 0 and is rewritten to the retain-forever
// sentinel by database.Webhook's BeforeSave hook. Anything unparseable // sentinel by database.Webhook's BeforeSave hook. Anything unparseable
// or negative is an error rather than a silently substituted default. // or negative is refused rather than silently given a default.
// //
// The upper bound is not cosmetic. The reaper computes its cutoff as a // The upper bound is not cosmetic. The reaper computes its cutoff as a
// time.Duration, an int64 nanosecond count, so a day count above // time.Duration, an int64 nanosecond count, so a day count above
// database.MaxFiniteRetentionDays overflows, puts the cutoff in the // database.MaxFiniteRetentionDays overflows, puts the cutoff in the
// future, and deletes every event the webhook has. A finite value // future, and deletes every event the webhook has. A finite value
// above that ceiling is therefore a 400. // above that ceiling is therefore refused, and the message names the
// ceiling rather than implying the input was not a number.
// //
// A value at or above the retain-forever sentinel is not out of range: // A value at or above the retain-forever sentinel is not out of range:
// it is what the edit form pre-fills for a retain-forever webhook, so // it is what the edit form pre-fills for a retain-forever webhook, so
// submitting the form back unchanged has to keep meaning "forever" // submitting the form back unchanged has to keep meaning "forever"
// rather than being rejected. // rather than being rejected.
func parseRetentionDays(raw string, fallback int) (int, error) { func parseRetentionDays(raw string, fallback int) (int, string) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
return fallback, nil return fallback, ""
} }
v, err := strconv.Atoi(raw) v, err := strconv.Atoi(raw)
if err != nil || v < 0 { if err != nil || v < 0 {
return 0, errInvalidRetention return 0, "Retention must be a whole number of days, or 0 to " +
"retain events forever."
} }
if v >= database.RetentionForeverDays { if v >= database.RetentionForeverDays {
return database.RetentionForeverDays, nil return database.RetentionForeverDays, ""
} }
if v > database.MaxFiniteRetentionDays { if v > database.MaxFiniteRetentionDays {
return 0, errRetentionTooLarge return 0, "Retention must be at most " +
strconv.Itoa(database.MaxFiniteRetentionDays) +
" days, or 0 to retain events forever."
} }
return v, nil return v, ""
} }
// DeliveryView is the display-safe projection of a delivery // DeliveryView is the display-safe projection of a delivery
@@ -154,7 +147,12 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
return return
} }
items := h.buildWebhookListItems(webhooks) items, err := h.buildWebhookListItems(webhooks)
if err != nil {
h.serverError(w, r, "failed to list webhooks", err)
return
}
data := map[string]any{ data := map[string]any{
"Webhooks": items, "Webhooks": items,
@@ -164,36 +162,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.
@@ -253,28 +330,25 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
retentionStr := r.PostFormValue("retention_days") retentionStr := r.PostFormValue("retention_days")
if name == "" { if name == "" {
w.WriteHeader(http.StatusBadRequest) h.renderTemplateStatus(
h.renderTemplate(
w, r, "sources_new.html", w, r, "sources_new.html",
newSourceFormData( newSourceFormData(
"Name is required", name, description, "Name is required", name, description,
), ),
http.StatusBadRequest,
) )
return return
} }
retentionDays, retErr := parseRetentionDays( retentionDays, errMsg := parseRetentionDays(
retentionStr, database.DefaultRetentionDays, retentionStr, database.DefaultRetentionDays,
) )
if retErr != nil { if errMsg != "" {
w.WriteHeader(http.StatusBadRequest) h.renderTemplateStatus(
h.renderTemplate(
w, r, "sources_new.html", w, r, "sources_new.html",
newSourceFormData( newSourceFormData(errMsg, name, description),
retentionErrorMessage(retErr), http.StatusBadRequest,
name, description,
),
) )
return return
@@ -322,7 +396,8 @@ func (h *Handlers) createWebhookWithEntrypoint(
) )
http.Redirect( http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther, w, r, withNotice("/hook/"+webhook.ID, webhookCreated),
http.StatusSeeOther,
) )
} }
@@ -504,6 +579,9 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
sourceID := chi.URLParam(r, "sourceID") sourceID := chi.URLParam(r, "sourceID")
h.renameMu.Lock()
defer h.renameMu.Unlock()
var webhook database.Webhook var webhook database.Webhook
err := h.db.DB().Where( err := h.db.DB().Where(
@@ -543,8 +621,7 @@ func (h *Handlers) applyWebhookEdit(
tmplKeyError: "Name is required", tmplKeyError: "Name is required",
} }
w.WriteHeader(http.StatusBadRequest) h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusBadRequest)
h.renderTemplate(w, r, "source_edit.html", data)
return return
} }
@@ -555,17 +632,16 @@ func (h *Handlers) applyWebhookEdit(
// An empty field falls back to the stored value, so submitting the // An empty field falls back to the stored value, so submitting the
// form without touching retention leaves the policy alone. // form without touching retention leaves the policy alone.
retentionDays, retErr := parseRetentionDays( retentionDays, errMsg := parseRetentionDays(
r.PostFormValue("retention_days"), webhook.RetentionDays, r.PostFormValue("retention_days"), webhook.RetentionDays,
) )
if retErr != nil { if errMsg != "" {
data := map[string]any{ data := map[string]any{
tmplKeyWebhook: webhook, tmplKeyWebhook: webhook,
tmplKeyError: retentionErrorMessage(retErr), tmplKeyError: errMsg,
} }
w.WriteHeader(http.StatusBadRequest) h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusBadRequest)
h.renderTemplate(w, r, "source_edit.html", data)
return return
} }
@@ -573,17 +649,18 @@ func (h *Handlers) applyWebhookEdit(
webhook.RetentionDays = retentionDays webhook.RetentionDays = retentionDays
// A new name renames the archive files before it is saved (see // A new name renames the archive files before it is saved (see
// delivery.Engine.Rename). If either step fails, they go back to // delivery.Engine.Rename). If either step fails, the same targets'
// the name that is still stored. // archives go back to the name that is still stored, without
err := h.renameWebhookArchives(webhook.ID, oldName, webhook.Name) // reading the main database again.
targets, err := h.renameWebhookArchives(
webhook.ID, oldName, webhook.Name,
)
if err == nil { if err == nil {
err = h.db.DB().Save(webhook).Error err = h.db.DB().Save(webhook).Error
} }
if err != nil { if err != nil {
restoreErr := h.renameWebhookArchives( restoreErr := h.renameArchives(targets, oldName)
webhook.ID, webhook.Name, oldName,
)
if restoreErr != nil { if restoreErr != nil {
h.log.Error( h.log.Error(
"failed to rename archives back", "failed to rename archives back",
@@ -596,12 +673,12 @@ func (h *Handlers) applyWebhookEdit(
data := map[string]any{ data := map[string]any{
tmplKeyWebhook: webhook, tmplKeyWebhook: webhook,
tmplKeyError: "Not saved: " + err.Error() + tmplKeyError: "Not saved: " + err.Error() +
". Move that file out of the data directory, " + ". Move that archive out of the data directory, " +
"then save again.", "its .db together with any -wal and -shm beside " +
"it, then save again.",
} }
w.WriteHeader(http.StatusConflict) h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusConflict)
h.renderTemplate(w, r, "source_edit.html", data)
return return
} }
@@ -612,7 +689,8 @@ func (h *Handlers) applyWebhookEdit(
} }
http.Redirect( http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther, w, r, withNotice("/hook/"+webhook.ID, webhookSaved),
http.StatusSeeOther,
) )
} }
@@ -695,7 +773,9 @@ func (h *Handlers) deleteWebhookResources(
return return
} }
http.Redirect(w, r, "/hooks", http.StatusSeeOther) http.Redirect(
w, r, withNotice("/hooks", webhookDeleted), http.StatusSeeOther,
)
} }
// commitWebhookDeletion soft-deletes a webhook's entrypoints, // commitWebhookDeletion soft-deletes a webhook's entrypoints,
@@ -774,12 +854,13 @@ func (h *Handlers) evictTargetArchiveWriter(targetID string) {
// renameWebhookArchives renames the archive file of every database // renameWebhookArchives renames the archive file of every database
// target of a webhook from the webhook name oldName to newName, // target of a webhook from the webhook name oldName to newName,
// keeping each target's own name. It does nothing when the name is // keeping each target's own name. It does nothing when the name is
// unchanged, and stops at the first failure. // unchanged. It returns the targets it read, so that a failed edit can
// move those same archives back with renameArchives.
func (h *Handlers) renameWebhookArchives( func (h *Handlers) renameWebhookArchives(
webhookID, oldName, newName string, webhookID, oldName, newName string,
) error { ) ([]database.Target, error) {
if h.archives == nil || oldName == newName { if h.archives == nil || oldName == newName {
return nil return nil, nil
} }
var targets []database.Target var targets []database.Target
@@ -791,19 +872,32 @@ func (h *Handlers) renameWebhookArchives(
). ).
Find(&targets).Error Find(&targets).Error
if err != nil { if err != nil {
return err return nil, err
} }
return targets, h.renameArchives(targets, newName)
}
// renameArchives renames the archive file of each of the given
// database targets to the webhook name webhookName, keeping each
// target's own name. It tries every target even after one fails, so
// that moving the archives back after a failed edit leaves none under
// the new name, and returns every failure joined.
func (h *Handlers) renameArchives(
targets []database.Target, webhookName string,
) error {
var errs []error
for i := range targets { for i := range targets {
err = h.archives.Rename( err := h.archives.Rename(
targets[i].ID, newName, targets[i].Name, targets[i].ID, webhookName, targets[i].Name,
) )
if err != nil { if err != nil {
return err errs = append(errs, err)
} }
} }
return nil return errors.Join(errs...)
} }
// ownedWebhook resolves the request's sourceID parameter to a // ownedWebhook resolves the request's sourceID parameter to a
@@ -882,31 +976,16 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
totalPages++ totalPages++
} }
// The banner a replay or resubmit POST redirected back
// with. The message comes from a fixed set keyed by the
// outcome code, never from the query string itself.
replayMsg, replayOK := replayOutcome(
r.URL.Query().Get(replayOutcomeParam),
)
resubmitMsg, resubmitOK := resubmitOutcome(
r.URL.Query().Get(resubmitOutcomeParam),
)
data := map[string]any{ data := map[string]any{
tmplKeyWebhook: &webhook, tmplKeyWebhook: &webhook,
"Events": evts, "Events": evts,
"ReplayMessage": replayMsg, "Page": page,
"ReplayQueued": replayOK, "TotalPages": totalPages,
"ResubmitMessage": resubmitMsg, "TotalEvents": total,
"ResubmitQueued": resubmitOK, "HasPrev": page > 1,
"Page": page, "HasNext": page < totalPages,
"TotalPages": totalPages, "PrevPage": page - 1,
"TotalEvents": total, "NextPage": page + 1,
"HasPrev": page > 1,
"HasNext": page < totalPages,
"PrevPage": page - 1,
"NextPage": page + 1,
} }
h.renderTemplate(w, r, "source_logs.html", data) h.renderTemplate(w, r, "source_logs.html", data)
@@ -1295,7 +1374,8 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
} }
http.Redirect( http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther, w, r, withNotice("/hook/"+webhook.ID, entrypointAdded),
http.StatusSeeOther,
) )
} }
} }
@@ -1314,6 +1394,9 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
sourceID := chi.URLParam(r, "sourceID") sourceID := chi.URLParam(r, "sourceID")
h.renameMu.Lock()
defer h.renameMu.Unlock()
var webhook database.Webhook var webhook database.Webhook
err := h.db.DB().Where( err := h.db.DB().Where(
@@ -1406,7 +1489,8 @@ func (h *Handlers) processTargetCreate(
} }
http.Redirect( http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther, w, r, withNotice("/hook/"+webhook.ID, targetAdded),
http.StatusSeeOther,
) )
} }
@@ -1610,10 +1694,11 @@ func (h *Handlers) validateTargetURL(
msg := "Invalid target URL: " + err.Error() msg := "Invalid target URL: " + err.Error()
// Only a private or reserved address's refusal says how // Only a private or reserved address's refusal says how
// to allow it. Metadata refusals never do: link-local and // to allow it. Other refusals never do: link-local, the
// the other unconditional metadata addresses cannot be // unspecified addresses and the unconditional metadata
// opened, and the default blocklist's public addresses, // addresses cannot be opened, and the default
// which listing does open, hand out credentials. // blocklist's public addresses, which listing does open,
// hand out credentials.
if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) { if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) {
msg += ". Private and reserved addresses are refused " + msg += ". Private and reserved addresses are refused " +
"by default; the server's ALLOWED_EGRESS_CIDRS " + "by default; the server's ALLOWED_EGRESS_CIDRS " +
@@ -1684,6 +1769,7 @@ func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
"entrypointID", &database.Entrypoint{}, "entrypointID", &database.Entrypoint{},
"failed to delete entrypoint", "failed to delete entrypoint",
nil, nil,
entrypointDeleted,
) )
} }
@@ -1695,18 +1781,21 @@ func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
"targetID", &database.Target{}, "targetID", &database.Target{},
"failed to delete target", "failed to delete target",
h.evictTargetArchiveWriter, h.evictTargetArchiveWriter,
targetDeleted,
) )
} }
// deleteChildResource returns a handler that deletes a child // deleteChildResource returns a handler that deletes a child
// resource (entrypoint or target) belonging to a webhook. The // resource (entrypoint or target) belonging to a webhook. The
// optional afterDelete hook runs with the child's id once the // optional afterDelete hook runs with the child's id once the
// delete has removed it, before the redirect. // delete has removed it, before the redirect, which carries done as
// its notice.
func (h *Handlers) deleteChildResource( func (h *Handlers) deleteChildResource(
idParam string, idParam string,
model any, model any,
errMsg string, errMsg string,
afterDelete func(childID string), afterDelete func(childID string),
done noticeCode,
) http.HandlerFunc { ) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r) userID, ok := h.getUserID(r)
@@ -1750,7 +1839,7 @@ func (h *Handlers) deleteChildResource(
http.Redirect( http.Redirect(
w, r, w, r,
"/hook/"+webhook.ID, withNotice("/hook/"+webhook.ID, done),
http.StatusSeeOther, http.StatusSeeOther,
) )
} }
@@ -1761,7 +1850,7 @@ func (h *Handlers) deleteChildResource(
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc { func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
return h.toggleChildResource( return h.toggleChildResource(
"entrypointID", "entrypointID",
func(webhookID, childID string) error { func(webhookID, childID string) (bool, error) {
var ep database.Entrypoint var ep database.Entrypoint
err := h.db.DB().Where( err := h.db.DB().Where(
@@ -1769,14 +1858,15 @@ func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
childID, webhookID, childID, webhookID,
).First(&ep).Error ).First(&ep).Error
if err != nil { if err != nil {
return err return false, err
} }
ep.Active = !ep.Active ep.Active = !ep.Active
return h.db.DB().Save(&ep).Error return ep.Active, h.db.DB().Save(&ep).Error
}, },
"failed to toggle entrypoint", "failed to toggle entrypoint",
entrypointActivated, entrypointDeactivated,
) )
} }
@@ -1784,7 +1874,7 @@ func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
func (h *Handlers) HandleTargetToggle() http.HandlerFunc { func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
return h.toggleChildResource( return h.toggleChildResource(
"targetID", "targetID",
func(webhookID, childID string) error { func(webhookID, childID string) (bool, error) {
var tgt database.Target var tgt database.Target
err := h.db.DB().Where( err := h.db.DB().Where(
@@ -1792,23 +1882,31 @@ func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
childID, webhookID, childID, webhookID,
).First(&tgt).Error ).First(&tgt).Error
if err != nil { if err != nil {
return err return false, err
} }
tgt.Active = !tgt.Active // Only the active column: saving the whole row would
// write back the name and settings read above over an
// edit saved since.
active := !tgt.Active
return h.db.DB().Save(&tgt).Error return active, h.db.DB().Model(&tgt).
Update("active", active).Error
}, },
"failed to toggle target", "failed to toggle target",
targetActivated, targetDeactivated,
) )
} }
// toggleChildResource returns a handler that toggles the active // toggleChildResource returns a handler that toggles the active
// state of a child resource belonging to a webhook. // state of a child resource belonging to a webhook. toggleFn returns
// the new state, and the redirect carries activated or deactivated as
// its notice to match.
func (h *Handlers) toggleChildResource( func (h *Handlers) toggleChildResource(
idParam string, idParam string,
toggleFn func(webhookID, childID string) error, toggleFn func(webhookID, childID string) (bool, error),
errMsg string, errMsg string,
activated, deactivated noticeCode,
) http.HandlerFunc { ) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r) userID, ok := h.getUserID(r)
@@ -1834,16 +1932,21 @@ func (h *Handlers) toggleChildResource(
return return
} }
err = toggleFn(webhook.ID, childID) active, err := toggleFn(webhook.ID, childID)
if err != nil { if err != nil {
h.serverError(w, r, errMsg, err) h.serverError(w, r, errMsg, err)
return return
} }
done := deactivated
if active {
done = activated
}
http.Redirect( http.Redirect(
w, r, w, r,
"/hook/"+webhook.ID, withNotice("/hook/"+webhook.ID, done),
http.StatusSeeOther, http.StatusSeeOther,
) )
} }
+232 -20
View File
@@ -2,16 +2,20 @@ package handlers_test
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
@@ -364,31 +368,42 @@ func TestHandleSourceCreateSubmit_OverflowingRetentionIsRejected(
// boundary between "too large to represent" and "retain forever": the // boundary between "too large to represent" and "retain forever": the
// sentinel is above MaxFiniteRetentionDays, but it is the value the // sentinel is above MaxFiniteRetentionDays, but it is the value the
// edit form pre-fills, so it must be accepted rather than rejected as // edit form pre-fills, so it must be accepted rather than rejected as
// out of range. // out of range. A value above the sentinel is stored as the sentinel.
func TestHandleSourceCreateSubmit_SentinelIsAcceptedAsForever( func TestHandleSourceCreateSubmit_SentinelIsAcceptedAsForever(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
env := setupSourceTest(t) for _, days := range []int{
sentinel := strconv.Itoa(database.RetentionForeverDays)
w := submitCreate(t, env.handlers, env.cookies, "forever", &sentinel)
require.Equal(t, http.StatusSeeOther, w.Code)
wh := onlyWebhook(t, env.db)
assert.Equal(
t,
database.RetentionForeverDays, database.RetentionForeverDays,
storedRetentionDays(t, env.db, wh.ID), database.RetentionForeverDays + 1,
) } {
raw := strconv.Itoa(days)
t.Run(raw, func(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
w := submitCreate(t, env.handlers, env.cookies, "forever", &raw)
require.Equal(t, http.StatusSeeOther, w.Code)
wh := onlyWebhook(t, env.db)
assert.Equal(
t,
database.RetentionForeverDays,
storedRetentionDays(t, env.db, wh.ID),
)
})
}
} }
// TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput checks that a // TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput checks that a
// validation failure hands the user's typing back, matching what the // validation failure hands the user's typing back, matching what the
// edit form already does. Losing a long description to a mistyped // edit form already does. Losing a long description to a mistyped
// retention value is the kind of thing that makes people give up on a // retention value is the kind of thing that makes people give up on a
// form. // form. Both values carry HTML-special characters, which must come
// back escaped rather than as markup.
func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput( func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
t *testing.T, t *testing.T,
) { ) {
@@ -397,8 +412,8 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
env := setupSourceTest(t) env := setupSourceTest(t)
const ( const (
name = "kept-name" name = `kept"><b>name`
description = "a description worth not losing" description = `a </textarea> worth not losing`
) )
form := url.Values{} form := url.Values{}
@@ -415,8 +430,10 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
body := w.Body.String() body := w.Body.String()
assert.Contains(t, body, `value="`+name+`"`) assert.Contains(t, body, `value="kept&#34;&gt;&lt;b&gt;name"`)
assert.Contains(t, body, description) assert.Contains(t, body, `a &lt;/textarea&gt; worth not losing`)
assert.NotContains(t, body, name)
assert.NotContains(t, body, description)
} }
// submitEdit posts the webhook edit form for the given webhook. // submitEdit posts the webhook edit form for the given webhook.
@@ -550,7 +567,7 @@ func TestHandleSourceEditSubmit_FailedRenameKeepsTheName(
wh := seedWebhookWithRetention(t, env.db, 7) wh := seedWebhookWithRetention(t, env.db, 7)
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
env.archives.FailRenames(errInjectedRename) env.archives.FailRenames(tgt.ID, errInjectedRename)
oldName := wh.Name oldName := wh.Name
wh.Name = renamedWebhookName wh.Name = renamedWebhookName
@@ -575,6 +592,201 @@ func TestHandleSourceEditSubmit_FailedRenameKeepsTheName(
) )
} }
// TestHandleSourceEditSubmit_FailedSaveRenamesBack proves that when
// the archive is renamed but the new name cannot be saved, the
// archive is renamed back to the stored name and the stored name
// stays.
func TestHandleSourceEditSubmit_FailedSaveRenamesBack(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
failSaveOnTable(t, env.db, "webhooks")
oldName := wh.Name
wh.Name = renamedWebhookName
w := submitEdit(t, env, wh, "")
require.Equal(t, http.StatusInternalServerError, w.Code)
var stored database.Webhook
require.NoError(
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
)
assert.Equal(t, oldName, stored.Name)
assert.Equal(
t,
[]archiveRename{
{tgt.ID, renamedWebhookName, tgt.Name},
{tgt.ID, oldName, tgt.Name},
},
env.archives.Renames(),
)
}
// errInjectedRead is the failure a test makes reads of the main
// database report.
var errInjectedRead = errors.New("injected read failure")
// TestHandleSourceEditSubmit_FailedSaveRenamesBackWithoutReading
// proves that when the save fails and every later read of the main
// database fails too, each archive the rename moved is still renamed
// back: the move back needs no second read of the webhook's targets.
func TestHandleSourceEditSubmit_FailedSaveRenamesBackWithoutReading(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
first := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
second := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
var saveFailed atomic.Bool
require.NoError(t, env.db.DB().Callback().Update().
Before("gorm:update").
Register("test:fail_save", func(tx *gorm.DB) {
saveFailed.Store(true)
_ = tx.AddError(errInjectedSave)
}),
)
require.NoError(t, env.db.DB().Callback().Query().
Before("gorm:query").
Register("test:fail_reads_after_save", func(tx *gorm.DB) {
if saveFailed.Load() {
_ = tx.AddError(errInjectedRead)
}
}),
)
oldName := wh.Name
wh.Name = renamedWebhookName
w := submitEdit(t, env, wh, "")
require.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(
t,
[]archiveRename{
{first.ID, renamedWebhookName, first.Name},
{second.ID, renamedWebhookName, second.Name},
{first.ID, oldName, first.Name},
{second.ID, oldName, second.Name},
},
env.archives.Renames(),
)
}
// TestHandleSourceEditSubmit_EditsDoNotInterleave proves that a second
// webhook edit submitted while the first is inside its archive rename
// does not run until the first is saved, so afterwards the stored
// names are the ones the archive was last renamed to. The stand-in's
// last rename is the name the file has on disk.
func TestHandleSourceEditSubmit_EditsDoNotInterleave(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
entered, release := env.archives.BlockNextRename()
firstEdit, secondEdit := wh, wh
firstEdit.Name = "First"
secondEdit.Name = "Second"
firstCode := make(chan int, 1)
go func() { firstCode <- submitEdit(t, env, firstEdit, "").Code }()
<-entered
secondCode := make(chan int, 1)
go func() { secondCode <- submitEdit(t, env, secondEdit, "").Code }()
// Were the edits not ordered, the second would run to its end in
// this time, while the first is still inside its rename.
time.Sleep(200 * time.Millisecond)
release()
assert.Equal(t, http.StatusSeeOther, <-firstCode)
assert.Equal(t, http.StatusSeeOther, <-secondCode)
var (
storedWebhook database.Webhook
storedTarget database.Target
)
require.NoError(
t, env.db.DB().First(&storedWebhook, "id = ?", wh.ID).Error,
)
require.NoError(
t, env.db.DB().First(&storedTarget, "id = ?", tgt.ID).Error,
)
renames := env.archives.Renames()
require.NotEmpty(t, renames)
assert.Equal(
t,
archiveRename{tgt.ID, storedWebhook.Name, storedTarget.Name},
renames[len(renames)-1],
)
}
// TestHandleSourceEditSubmit_FailedRenameRenamesTheOthersBack proves
// that when a webhook has three database targets and only the middle
// one's archive cannot be renamed, the stored name stays and both
// others are renamed back, the last one included: the move back does
// not stop at the target it cannot rename. The handler reaches the
// targets in the order they were created, which the exact sequence
// below pins, so the refused target always comes before the last.
func TestHandleSourceEditSubmit_FailedRenameRenamesTheOthersBack(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
first := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
middle := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
last := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
env.archives.FailRenames(middle.ID, errNameTaken)
oldName := wh.Name
wh.Name = renamedWebhookName
w := submitEdit(t, env, wh, "")
require.Equal(t, http.StatusConflict, w.Code)
var stored database.Webhook
require.NoError(
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
)
assert.Equal(t, oldName, stored.Name)
assert.Equal(
t,
[]archiveRename{
{first.ID, renamedWebhookName, first.Name},
{middle.ID, renamedWebhookName, middle.Name},
{last.ID, renamedWebhookName, last.Name},
{first.ID, oldName, first.Name},
{middle.ID, oldName, middle.Name},
{last.ID, oldName, last.Name},
},
env.archives.Renames(),
)
}
// TestHandleSourceEditSubmit_ArchiveNameTaken proves that when a file // TestHandleSourceEditSubmit_ArchiveNameTaken proves that when a file
// already has an archive's new name, the edit is refused with an // already has an archive's new name, the edit is refused with an
// error naming that file, and the webhook keeps its stored name. // error naming that file, and the webhook keeps its stored name.
@@ -583,9 +795,9 @@ func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) {
env := setupSourceTest(t) env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7) wh := seedWebhookWithRetention(t, env.db, 7)
seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) tgt := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
env.archives.FailRenames(errNameTaken) env.archives.FailRenames(tgt.ID, errNameTaken)
oldName := wh.Name oldName := wh.Name
wh.Name = renamedWebhookName wh.Name = renamedWebhookName
+8 -3
View File
@@ -80,6 +80,9 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
// HandleTargetEditSubmit handles the target edit form submission. // HandleTargetEditSubmit handles the target edit form submission.
func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc { func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
h.renameMu.Lock()
defer h.renameMu.Unlock()
webhook, target, ok := h.ownedTarget(w, r) webhook, target, ok := h.ownedTarget(w, r)
if !ok { if !ok {
return return
@@ -179,8 +182,9 @@ func (h *Handlers) applyTargetEdit(
http.Error( http.Error(
w, w,
"Not saved: "+err.Error()+ "Not saved: "+err.Error()+
". Move that file out of the data directory, "+ ". Move that archive out of the data directory, "+
"then save again.", "its .db together with any -wal and -shm beside "+
"it, then save again.",
http.StatusConflict, http.StatusConflict,
) )
@@ -193,7 +197,8 @@ func (h *Handlers) applyTargetEdit(
} }
http.Redirect( http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther, w, r, withNotice("/hook/"+webhook.ID, targetSaved),
http.StatusSeeOther,
) )
} }
+34 -2
View File
@@ -682,7 +682,7 @@ func TestHandleTargetEditSubmit_RenamesArchive(t *testing.T) {
again := url.Values{"name": {"Again"}} again := url.Values{"name": {"Again"}}
env.archives.FailRenames(errInjectedRename) env.archives.FailRenames(archive.ID, errInjectedRename)
w = submitTargetEdit(env, wh.ID, archive.ID, again) w = submitTargetEdit(env, wh.ID, archive.ID, again)
require.Equal(t, http.StatusInternalServerError, w.Code) require.Equal(t, http.StatusInternalServerError, w.Code)
@@ -691,7 +691,7 @@ func TestHandleTargetEditSubmit_RenamesArchive(t *testing.T) {
"a target whose archive was not renamed keeps its name", "a target whose archive was not renamed keeps its name",
) )
env.archives.FailRenames(errNameTaken) env.archives.FailRenames(archive.ID, errNameTaken)
w = submitTargetEdit(env, wh.ID, archive.ID, again) w = submitTargetEdit(env, wh.ID, archive.ID, again)
require.Equal(t, http.StatusConflict, w.Code) require.Equal(t, http.StatusConflict, w.Code)
@@ -700,3 +700,35 @@ func TestHandleTargetEditSubmit_RenamesArchive(t *testing.T) {
t, renamedTargetName, storedTarget(t, env, archive.ID).Name, t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
) )
} }
// TestHandleTargetEditSubmit_FailedSaveRenamesBack proves that when a
// database target's archive is renamed but the new name cannot be
// saved, the archive is renamed back to the stored name and the
// stored name stays.
func TestHandleTargetEditSubmit_FailedSaveRenamesBack(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh := seedWebhookWithRetention(t, env.db, 7)
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
failSaveOnTable(t, env.db, "targets")
form := url.Values{}
form.Set("name", renamedTargetName)
w := submitTargetEdit(env, wh.ID, archive.ID, form)
require.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(
t, archive.Name, storedTarget(t, env, archive.ID).Name,
)
assert.Equal(
t,
[]archiveRename{
{archive.ID, wh.Name, renamedTargetName},
{archive.ID, wh.Name, archive.Name},
},
env.archives.Renames(),
)
}
+66
View File
@@ -0,0 +1,66 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// TestHandleTargetToggle_DoesNotUndoAnEdit proves that a toggle which
// loaded the target before an edit of it was saved does not write the
// old name and settings back over the edit. The edit is submitted from
// a callback on the toggle's own read of the target, so it is saved
// after that read and before the toggle writes.
func TestHandleTargetToggle_DoesNotUndoAnEdit(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh, tgt := seedHTTPTarget(t, env, "", "")
require.True(t, tgt.Active)
var (
edited bool
editCode int
)
require.NoError(t, env.db.DB().Callback().Query().
After("gorm:query").
Register("test:edit_after_toggle_read", func(tx *gorm.DB) {
// The edit reads the target too; only the toggle's read,
// the first, submits it.
if tx.Statement.Table != "targets" || edited {
return
}
edited = true
editCode = submitTargetEdit(
env, wh.ID, tgt.ID,
editForm(editReplacedURL, "", ""),
).Code
}),
)
req := postRequest(
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/toggle",
env.cookies,
map[string]string{paramSourceID: wh.ID, paramTargetID: tgt.ID},
)
w := httptest.NewRecorder()
env.handlers.HandleTargetToggle().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code)
require.Equal(t, http.StatusSeeOther, editCode)
stored := storedTarget(t, env, tgt.ID)
assert.False(t, stored.Active)
assert.Equal(t, "edited-name", stored.Name)
assert.Equal(t, 5, stored.MaxRetries)
assert.Equal(
t, editReplacedURL, storedHTTPConfig(t, env, tgt.ID).URL,
)
}
+6 -2
View File
@@ -11,6 +11,7 @@ import (
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/logfield" "sneak.berlin/go/webhooker/internal/logfield"
"sneak.berlin/go/webhooker/internal/middleware"
) )
const ( const (
@@ -57,7 +58,8 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
h.log.Info("webhook request received", h.log.Info("webhook request received",
"entrypoint_uuid", entrypointUUID, "entrypoint_uuid", entrypointUUID,
"method", r.Method, "method", r.Method,
"remote_addr", r.RemoteAddr, "remoteIP", middleware.RemoteIP(r),
"clientIP", middleware.ClientIP(r),
) )
if !entrypoint.Active { if !entrypoint.Active {
@@ -150,7 +152,9 @@ func (h *Handlers) lookupEntrypoint(
return entrypoint, true return entrypoint, true
} }
// readWebhookBody reads and validates the request body size. // readWebhookBody reads and validates the request body size. This is
// the receiver's only body cap: /h/{uuid} has no MaxBodySize
// middleware (see Server.setupWebhookRoutes).
func (h *Handlers) readWebhookBody( func (h *Handlers) readWebhookBody(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
+124
View File
@@ -0,0 +1,124 @@
package handlers_test
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/middleware"
)
// TestHandleWebhook_LogsClientNextToThePeer checks that the
// receiver's "webhook request received" line carries both addresses:
// remoteIP, the connecting peer, and clientIP, the client the access
// log attributes the request to.
func TestHandleWebhook_LogsClientNextToThePeer(t *testing.T) {
t.Parallel()
// untrustedPeer is outside the trusted 10.0.0.0/8, so its
// X-Forwarded-For is ignored and it is the client.
const untrustedPeer = "192.0.2.10"
cases := map[string]struct {
peer string
wantRemote string
wantClient string
}{
"trusted proxy with a forwarded chain": {
peer: "10.0.0.1:44444",
wantRemote: "10.0.0.1",
wantClient: "198.51.100.7",
},
"untrusted peer": {
peer: untrustedPeer + ":5555",
wantRemote: untrustedPeer,
wantClient: untrustedPeer,
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
mw *middleware.Middleware
db *database.Database
)
app := newTestAppWithConfig(t, &config.Config{
DataDir: t.TempDir(),
TrustedProxies: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
},
}, &h, &mw, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
buf := new(bytes.Buffer)
h.SetLogForTest(slog.New(slog.NewJSONHandler(buf, nil)))
webhook := seedWebhook(t, db)
seedEntrypoint(t, db, webhook.ID)
// Logging is what works the client address out, so the
// request goes through it as it does in production.
router := chi.NewRouter()
router.Use(mw.Logging())
router.Post("/h/{uuid}", h.HandleWebhook())
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost,
"/h/ep-"+webhook.ID, strings.NewReader("{}"),
)
req.RemoteAddr = tc.peer
req.Header.Set("X-Forwarded-For", "198.51.100.7, 10.0.0.2")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
line := receivedLine(t, buf)
assert.Equal(t, tc.wantRemote, line["remoteIP"])
assert.Equal(t, tc.wantClient, line["clientIP"])
})
}
}
// receivedLine returns the one "webhook request received" line in the
// captured JSON log.
func receivedLine(t *testing.T, buf *bytes.Buffer) map[string]any {
t.Helper()
var found []map[string]any
for line := range strings.SplitSeq(
strings.TrimSpace(buf.String()), "\n",
) {
var entry map[string]any
require.NoError(t, json.Unmarshal([]byte(line), &entry))
if entry["msg"] == "webhook request received" {
found = append(found, entry)
}
}
require.Len(t, found, 1)
return found[0]
}
-4
View File
@@ -7,7 +7,6 @@ import (
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
@@ -18,7 +17,6 @@ type HealthcheckParams struct {
fx.In fx.In
Globals *globals.Globals Globals *globals.Globals
Config *config.Config
Logger *logger.Logger Logger *logger.Logger
Database *database.Database Database *database.Database
} }
@@ -64,7 +62,6 @@ func (s *Healthcheck) Healthcheck() *Response {
UptimeHuman: s.uptime().String(), UptimeHuman: s.uptime().String(),
Appname: s.params.Globals.Appname, Appname: s.params.Globals.Appname,
Version: s.params.Globals.Version, Version: s.params.Globals.Version,
Maintenance: s.params.Config.MaintenanceMode,
} }
return resp return resp
@@ -78,7 +75,6 @@ type Response struct {
UptimeHuman string `json:"uptimeHuman"` UptimeHuman string `json:"uptimeHuman"`
Version string `json:"version"` Version string `json:"version"`
Appname string `json:"appname"` Appname string `json:"appname"`
Maintenance bool `json:"maintenanceMode"`
} }
func (s *Healthcheck) uptime() time.Duration { func (s *Healthcheck) uptime() time.Duration {
+38
View File
@@ -9,6 +9,7 @@ import (
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxevent"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
) )
@@ -106,3 +107,40 @@ func (l *Logger) Identify() {
func (l *Logger) Writer() io.Writer { func (l *Logger) Writer() io.Writer {
return os.Stdout return os.Stdout
} }
// FxLogger writes fx's own events through a slog logger: how the
// dependency graph was built at DEBUG, since it repeats on every
// start; the start and stop hooks, the start itself and the signal
// that stops the service at INFO; every failure at ERROR.
//
// The formatting is fx's own fxevent.SlogLogger. That logger takes a
// single level for every event that is not a failure, so FxLogger
// holds one at each level and picks between them.
type FxLogger struct {
graph *fxevent.SlogLogger
lifecycle *fxevent.SlogLogger
}
// NewFxLogger returns an FxLogger that writes through log.
func NewFxLogger(log *slog.Logger) *FxLogger {
graph := &fxevent.SlogLogger{Logger: log}
graph.UseLogLevel(slog.LevelDebug)
lifecycle := &fxevent.SlogLogger{Logger: log}
lifecycle.UseLogLevel(slog.LevelInfo)
return &FxLogger{graph: graph, lifecycle: lifecycle}
}
// LogEvent implements fxevent.Logger.
func (f *FxLogger) LogEvent(event fxevent.Event) {
switch event.(type) {
case *fxevent.Supplied, *fxevent.Provided, *fxevent.Replaced,
*fxevent.Decorated, *fxevent.BeforeRun, *fxevent.Run,
*fxevent.Invoking, *fxevent.Invoked,
*fxevent.LoggerInitialized:
f.graph.LogEvent(event)
default:
f.lifecycle.LogEvent(event)
}
}
+54
View File
@@ -1,13 +1,23 @@
package logger_test package logger_test
import ( import (
"bytes"
"encoding/json"
"errors"
"log/slog"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
) )
var errStopHook = errors.New("stop hook failed on purpose")
func testGlobals() *globals.Globals { func testGlobals() *globals.Globals {
return &globals.Globals{ return &globals.Globals{
Appname: "test-app", Appname: "test-app",
@@ -57,3 +67,47 @@ func TestEnableDebugLogging(t *testing.T) {
// Test debug logging // Test debug logging
l.Get().Debug("debug message", "test", true) l.Get().Debug("debug message", "test", true)
} }
// TestFxLogger_Levels starts and stops an fx app that reports its own
// events through NewFxLogger, as cmd/webhooker does, and reads back
// what reached the handler: the graph at DEBUG, the start at INFO and
// a failed stop hook at ERROR, each as a structured record.
func TestFxLogger_Levels(t *testing.T) {
t.Parallel()
var out bytes.Buffer
log := slog.New(slog.NewJSONHandler(
&out, &slog.HandlerOptions{Level: slog.LevelDebug},
))
app := fx.New(
fx.WithLogger(func() fxevent.Logger {
return logger.NewFxLogger(log)
}),
fx.Invoke(func(lc fx.Lifecycle) {
lc.Append(fx.StopHook(func() error { return errStopHook }))
}),
)
require.NoError(t, app.Start(t.Context()))
require.ErrorIs(t, app.Stop(t.Context()), errStopHook)
levels := map[string]string{}
decoder := json.NewDecoder(&out)
for decoder.More() {
var record struct {
Level string `json:"level"`
Msg string `json:"msg"`
}
require.NoError(t, decoder.Decode(&record))
levels[record.Msg] = record.Level
}
assert.Equal(t, "DEBUG", levels["provided"])
assert.Equal(t, "INFO", levels["started"])
assert.Equal(t, "ERROR", levels["OnStop hook failed"])
}
+28 -20
View File
@@ -3,17 +3,18 @@
// deliveries are attempted, how they end, how long they take, how // deliveries are attempted, how they end, how long they take, how
// deep the queues are, and how many circuit breakers are open. // deep the queues are, and how many circuit breakers are open.
// //
// The inbound HTTP metrics come from the go-http-metrics recorder in // It also builds the registry the authenticated /metrics route
// internal/middleware and land on prometheus.DefaultRegisterer. These // serves. In production, these collectors, the inbound HTTP metrics
// collectors register there too, so both surfaces are gathered by the // recorded in internal/middleware, and the Go runtime and process
// one promhttp handler mounted on the authenticated /metrics route. // collectors all register on that one registry, never on Prometheus's
// global default.
package metrics package metrics
import ( import (
"sync"
"time" "time"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
@@ -57,25 +58,31 @@ var knownTargetTypes = []database.TargetType{
database.TargetTypeSlack, database.TargetTypeSlack,
} }
// defaultSet is the process-wide metric set, registered on the same // NewRegistry returns the registry /metrics serves, carrying the Go
// registry the HTTP middleware and the /metrics handler already use. // runtime and process collectors that Prometheus's global default
// It is built on first use rather than in an init so that a test // registry carries, so the go_* and process_* series stay in the
// binary that never touches metrics never registers them. // scrape.
// //
//nolint:gochecknoglobals // one process-wide registration, by design // A registry of its own, rather than the global default, is what lets
var defaultSet = sync.OnceValue(func() *Set { // two dependency graphs in one process — two tests, say — each
return New(prometheus.DefaultRegisterer) // register their collectors without the second registration
}) // panicking.
func NewRegistry() *prometheus.Registry {
reg := prometheus.NewRegistry()
reg.MustRegister(
collectors.NewGoCollector(),
collectors.NewProcessCollector(
collectors.ProcessCollectorOpts{},
),
)
// Default returns the process-wide metric set. return reg
func Default() *Set {
return defaultSet()
} }
// Set is one registered group of webhooker's delivery collectors. // Set is one registered group of webhooker's delivery collectors.
// Production uses the single Default set; tests build their own // Production builds one on the registry /metrics serves; tests build
// against a private registry so assertions are not disturbed by // one on a registry of their own so they can gather what their own
// deliveries other tests are making concurrently. // deliveries recorded.
type Set struct { type Set struct {
eventsReceived prometheus.Counter eventsReceived prometheus.Counter
deliveryAttempts *prometheus.CounterVec deliveryAttempts *prometheus.CounterVec
@@ -93,7 +100,7 @@ type Set struct {
// New registers a full set of delivery collectors on reg and returns // New registers a full set of delivery collectors on reg and returns
// it. It panics if reg already holds them, which is the intended // it. It panics if reg already holds them, which is the intended
// behaviour for a duplicate registration. // behaviour for a duplicate registration.
func New(reg prometheus.Registerer) *Set { func New(reg *prometheus.Registry) *Set {
factory := promauto.With(reg) factory := promauto.With(reg)
s := &Set{ s := &Set{
@@ -377,6 +384,7 @@ func (s *Set) initSeries() {
s.deliveriesFailed.WithLabelValues(label) s.deliveriesFailed.WithLabelValues(label)
s.deliveryRetries.WithLabelValues(label) s.deliveryRetries.WithLabelValues(label)
s.deliveryReplays.WithLabelValues(label) s.deliveryReplays.WithLabelValues(label)
s.deliveryDuration.WithLabelValues(label)
s.deliveriesPending.WithLabelValues(label) s.deliveriesPending.WithLabelValues(label)
s.deliveriesRetrying.WithLabelValues(label) s.deliveriesRetrying.WithLabelValues(label)
s.circuitBreakersOpen.WithLabelValues(label) s.circuitBreakersOpen.WithLabelValues(label)
+1
View File
@@ -167,6 +167,7 @@ func TestKnownSeriesExistBeforeAnyDelivery(t *testing.T) {
"webhooker_deliveries_succeeded_total", "webhooker_deliveries_succeeded_total",
"webhooker_deliveries_failed_total", "webhooker_deliveries_failed_total",
"webhooker_delivery_retries_total", "webhooker_delivery_retries_total",
"webhooker_delivery_duration_seconds",
"webhooker_circuit_breakers_open", "webhooker_circuit_breakers_open",
} { } {
assert.ElementsMatch(t, assert.ElementsMatch(t,
+55 -11
View File
@@ -63,6 +63,12 @@ const (
// capturingMiddleware returns a Middleware whose logger writes JSON // capturingMiddleware returns a Middleware whose logger writes JSON
// lines into the returned buffer, so the access log can be asserted // lines into the returned buffer, so the access log can be asserted
// on directly. // on directly.
//
// It trusts 192.0.2.1, the peer address httptest.NewRequestWithContext
// gives a request, as a proxy, the way a deployment trusts its reverse
// proxy: a request built that way and carrying X-Forwarded-For is
// logged with the client that header names as clientIP, and one
// without it with the peer.
func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) { func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
t.Helper() t.Helper()
@@ -72,7 +78,10 @@ func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
&slog.HandlerOptions{Level: slog.LevelInfo}, &slog.HandlerOptions{Level: slog.LevelInfo},
)) ))
cfg := &config.Config{Environment: config.EnvironmentDev} cfg := &config.Config{
Environment: config.EnvironmentDev,
TrustedProxies: trustedProxies("192.0.2.1/32"),
}
return middleware.NewForTest(log, cfg, nil), buf return middleware.NewForTest(log, cfg, nil), buf
} }
@@ -81,7 +90,7 @@ func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
// internal/logger can select: slog's text handler, which // internal/logger can select: slog's text handler, which
// internal/logger/logger.go installs when stderr is a tty. It escapes // internal/logger/logger.go installs when stderr is a tty. It escapes
// differently from the JSON one, so the line bound has to be asserted // differently from the JSON one, so the line bound has to be asserted
// against both. // against both. It trusts the same peer.
func capturingTextMiddleware( func capturingTextMiddleware(
t *testing.T, t *testing.T,
) (*middleware.Middleware, *bytes.Buffer) { ) (*middleware.Middleware, *bytes.Buffer) {
@@ -93,7 +102,10 @@ func capturingTextMiddleware(
&slog.HandlerOptions{Level: slog.LevelInfo}, &slog.HandlerOptions{Level: slog.LevelInfo},
)) ))
cfg := &config.Config{Environment: config.EnvironmentDev} cfg := &config.Config{
Environment: config.EnvironmentDev,
TrustedProxies: trustedProxies("192.0.2.1/32"),
}
return middleware.NewForTest(log, cfg, nil), buf return middleware.NewForTest(log, cfg, nil), buf
} }
@@ -334,11 +346,12 @@ func oversizedHeaders(value string) map[string]string {
// sizeCase is one way of pointing 8 KB of client-chosen text at the // sizeCase is one way of pointing 8 KB of client-chosen text at the
// access log. // access log.
type sizeCase struct { type sizeCase struct {
target string target string
headers map[string]string headers map[string]string
wantStatus int wantStatus int
wantURL string wantURL string
bound int wantClientIP string
bound int
} }
// lineSizeCases enumerates every part of a request that reaches the // lineSizeCases enumerates every part of a request that reaches the
@@ -375,8 +388,7 @@ func lineSizeCases() map[string]sizeCase {
} }
// The url field on a 5xx keeps the concrete path, so it reaches its // The url field on a 5xx keeps the concrete path, so it reaches its
// own budget on the same line as the three header fields. That is // own budget on the same line as the three header fields.
// the widest access log line the service can be made to write.
longPath := "/boom/" + strings.Repeat("x", oversizedSegmentBytes) longPath := "/boom/" + strings.Repeat("x", oversizedSegmentBytes)
wantLongURL := longPath[:maxFieldBytes] + truncationSuffix wantLongURL := longPath[:maxFieldBytes] + truncationSuffix
@@ -420,6 +432,29 @@ func lineSizeCases() map[string]sizeCase {
} }
} }
// From a trusted proxy, clientIP is read out of X-Forwarded-For,
// which the client writes. What bounds the field is that only one
// address from the header is written, and it is written parsed, with
// no zone. An IPv6 address with all eight groups at four digits is
// the longest such address; here it carries an 8 KB zone, which must
// not reach the line. It goes on the 5xx line with all three header
// fields at their budget.
const longestIPv6 = "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
forwarded := oversizedHeaders(oversizedValue("h"))
forwarded[headerXFF] = oversizedValue("h") + ", " +
longestIPv6 + "%" + oversizedValue("h")
cases["oversized X-Forwarded-For from a trusted proxy "+
"with a 5xx concrete url"] = sizeCase{
target: longPath,
headers: forwarded,
wantStatus: http.StatusInternalServerError,
wantURL: wantLongURL,
wantClientIP: longestIPv6,
bound: maxCappedLineBytes,
}
return cases return cases
} }
@@ -460,6 +495,14 @@ func TestAccessLog_LineSizeDoesNotTrackInputSize(t *testing.T) {
require.Len(t, entries, 1) require.Len(t, entries, 1)
assert.Equal(t, tc.wantURL, entries[0]["url"]) assert.Equal(t, tc.wantURL, entries[0]["url"])
// Set only by the X-Forwarded-For case, where it proves
// the header was read rather than ignored.
if tc.wantClientIP != "" {
assert.Equal(
t, tc.wantClientIP, entries[0]["clientIP"],
)
}
// The markers sit at the far end of the client-chosen // The markers sit at the far end of the client-chosen
// text, so their absence is what proves the redaction and // text, so their absence is what proves the redaction and
// the truncation actually ran. // the truncation actually ran.
@@ -648,7 +691,8 @@ func TestAccessLog_RetainsEveryOtherField(t *testing.T) {
for _, key := range []string{ for _, key := range []string{
"request_start", "method", "url", "useragent", "request_id", "request_start", "method", "url", "useragent", "request_id",
"referer", "proto", "remoteIP", "status", "latency_ms", "referer", "proto", "remoteIP", "clientIP", "status",
"latency_ms",
} { } {
assert.Contains(t, entries[0], key) assert.Contains(t, entries[0], key)
} }
+200
View File
@@ -0,0 +1,200 @@
package middleware_test
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/middleware"
)
const (
// forwardedChain is the X-Forwarded-For a request arrives with:
// the client, then a second proxy inside trustedProxyCIDR that the
// request passed through before reaching trustedPeer.
forwardedChain = clientIPv4 + ", 10.0.0.2"
// untrustedPeer is a peer outside trustedProxyCIDR, so its
// X-Forwarded-For is ignored and the peer is the client.
untrustedPeer = "192.0.2.10:5555"
// oneRequestPerMinute is the receiver limit these tests install:
// the second request on a path is rejected, and the aggregate
// limit is ReceiverAggregateMultiplierConst.
oneRequestPerMinute = 1
)
// clientLogSite is one log line that names the client. build wraps the
// middleware that writes it around a handler, and requests is how many
// identical requests it takes before the line is written.
type clientLogSite struct {
build func(m *middleware.Middleware) http.Handler
requests int
}
// clientLogSites maps the message of each line that names the client
// to the way to make it be written.
func clientLogSites() map[string]clientLogSite {
served := func(*middleware.Middleware) http.Handler {
return okHandler()
}
receiver := func(m *middleware.Middleware) http.Handler {
return m.ReceiverRateLimit()(okHandler())
}
login := func(m *middleware.Middleware) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
m.RecordLoginFailure(r, "someone")
w.WriteHeader(http.StatusUnauthorized)
},
)
}
csrf := func(m *middleware.Middleware) http.Handler {
return m.CSRF(http.HandlerFunc(forbidden))(okHandler())
}
passwordChange := func(m *middleware.Middleware) http.Handler {
return m.PasswordChangeRateLimit()(okHandler())
}
replay := func(m *middleware.Middleware) http.Handler {
return m.ReplayRateLimit()(okHandler())
}
resubmit := func(m *middleware.Middleware) http.Handler {
return m.ResubmitRateLimit()(okHandler())
}
return map[string]clientLogSite{
"http request": {
build: served,
requests: 1,
},
"webhook receiver rate limit exceeded": {
build: receiver,
requests: oneRequestPerMinute + 1,
},
// The aggregate limit sits in front of the per-entrypoint
// one, so the requests that one rejects count towards it.
"webhook receiver aggregate rate limit exceeded": {
build: receiver,
requests: middleware.ReceiverAggregateMultiplierConst*
oneRequestPerMinute + 1,
},
"login failure limit exceeded": {
build: login,
requests: middleware.LoginRateLimitConst + 1,
},
"csrf: token validation failed": {
build: csrf,
requests: 1,
},
"password change rate limit exceeded": {
build: passwordChange,
requests: middleware.PasswordChangeRateLimitConst + 1,
},
"delivery replay rate limit exceeded": {
build: replay,
requests: middleware.ReplayRateLimitConst + 1,
},
"event resubmit rate limit exceeded": {
build: resubmit,
requests: middleware.ResubmitRateLimitConst + 1,
},
}
}
// clientLogLines sends the site's requests from peer, each carrying
// forwardedChain, through Logging and then the site, as production
// does, and returns the logged lines whose message is msg.
func clientLogLines(
t *testing.T, site clientLogSite, msg, peer string,
) []map[string]any {
t.Helper()
buf := new(bytes.Buffer)
log := slog.New(slog.NewJSONHandler(
buf,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
cfg := &config.Config{
Environment: config.EnvironmentDev,
ReceiverRateLimit: oneRequestPerMinute,
TrustedProxies: trustedProxies(trustedProxyCIDR),
}
m := middleware.NewForTest(
log, cfg, newTestSessionManager(cfg, log, nil),
)
handler := m.Logging()(site.build(m))
for range site.requests {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/h/x", nil,
)
req.RemoteAddr = peer
req.Header.Set(headerXFF, forwardedChain)
handler.ServeHTTP(httptest.NewRecorder(), req)
}
var lines []map[string]any
for _, entry := range accessLogEntries(t, buf) {
if entry["msg"] == msg {
lines = append(lines, entry)
}
}
return lines
}
// TestClientIP_LoggedNextToThePeer checks that every line that names
// the client carries both addresses: remoteIP, the connecting peer,
// and clientIP, the client the rate limiters key on.
func TestClientIP_LoggedNextToThePeer(t *testing.T) {
t.Parallel()
cases := map[string]struct {
peer string
wantRemote string
wantClient string
}{
"trusted proxy with a forwarded chain": {
peer: trustedPeer,
wantRemote: "10.0.0.1",
wantClient: clientIPv4,
},
"untrusted peer": {
peer: untrustedPeer,
wantRemote: "192.0.2.10",
wantClient: "192.0.2.10",
},
}
for msg, site := range clientLogSites() {
for name, tc := range cases {
t.Run(msg+"/"+name, func(t *testing.T) {
t.Parallel()
lines := clientLogLines(t, site, msg, tc.peer)
require.NotEmpty(t, lines, "%q was never logged", msg)
for _, line := range lines {
assert.Equal(t, tc.wantRemote, line["remoteIP"])
assert.Equal(t, tc.wantClient, line["clientIP"])
}
})
}
}
}
+5 -4
View File
@@ -45,10 +45,10 @@ func (m *Middleware) CSRF(
// unauthenticated client: a POST with no token to // unauthenticated client: a POST with no token to
// /hook/<any length of any text>/edit lands here. The // /hook/<any length of any text>/edit lands here. The
// method and path are capped against the same budgets as // method and path are capped against the same budgets as
// the access log. remote_addr is set by net/http from the // the access log. remoteIP and clientIP are the same
// accepted connection rather than by the client, and // addresses the access log carries, and
// csrf.FailureReason returns one of gorilla/csrf's own // csrf.FailureReason returns one of gorilla/csrf's own
// fixed error values, so neither is client-sized. // fixed error values, so none of them is client-sized.
m.log.Warn("csrf: token validation failed", m.log.Warn("csrf: token validation failed",
"method", logfield.Truncate( "method", logfield.Truncate(
r.Method, maxLogMethodBytes, r.Method, maxLogMethodBytes,
@@ -56,7 +56,8 @@ func (m *Middleware) CSRF(
"path", logfield.Truncate( "path", logfield.Truncate(
r.URL.Path, logfield.MaxBytes, r.URL.Path, logfield.MaxBytes,
), ),
"remote_addr", r.RemoteAddr, "remoteIP", RemoteIP(r),
"clientIP", ClientIP(r),
"reason", csrf.FailureReason(r), "reason", csrf.FailureReason(r),
) )
forbidden.ServeHTTP(w, r) forbidden.ServeHTTP(w, r)
+7 -2
View File
@@ -10,8 +10,7 @@ import (
// MetricsMiddlewareForTest builds the metrics recording middleware // MetricsMiddlewareForTest builds the metrics recording middleware
// against a caller-supplied recorder, so a test can gather from its // against a caller-supplied recorder, so a test can gather from its
// own Prometheus registry rather than the process-wide default one // own Prometheus registry without building a whole Middleware.
// that Middleware.Metrics uses.
func MetricsMiddlewareForTest( func MetricsMiddlewareForTest(
rec httpmetrics.Recorder, rec httpmetrics.Recorder,
) func(http.Handler) http.Handler { ) func(http.Handler) http.Handler {
@@ -133,6 +132,12 @@ func (g *LoginGuard) TrackedKeysForTest() (int, int) {
// passwordChangeRateLimit constant. // passwordChangeRateLimit constant.
const PasswordChangeRateLimitConst = passwordChangeRateLimit const PasswordChangeRateLimitConst = passwordChangeRateLimit
// ReplayRateLimitConst exposes the replayRateLimit constant.
const ReplayRateLimitConst = replayRateLimit
// ResubmitRateLimitConst exposes the resubmitRateLimit constant.
const ResubmitRateLimitConst = resubmitRateLimit
// ReceiverAggregateMultiplierConst exposes the // ReceiverAggregateMultiplierConst exposes the
// receiverAggregateMultiplier constant. // receiverAggregateMultiplier constant.
const ReceiverAggregateMultiplierConst = receiverAggregateMultiplier const ReceiverAggregateMultiplierConst = receiverAggregateMultiplier
+2
View File
@@ -385,6 +385,8 @@ func (m *Middleware) RecordLoginFailure(
"path", logfield.Truncate( "path", logfield.Truncate(
r.URL.Path, logfield.MaxBytes, r.URL.Path, logfield.MaxBytes,
), ),
"remoteIP", RemoteIP(r),
"clientIP", ClientIP(r),
) )
} }
+74 -17
View File
@@ -7,9 +7,7 @@ import (
"github.com/go-chi/chi" "github.com/go-chi/chi"
httpmetrics "github.com/slok/go-http-metrics/metrics" httpmetrics "github.com/slok/go-http-metrics/metrics"
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware" ghmm "github.com/slok/go-http-metrics/middleware"
"github.com/slok/go-http-metrics/middleware/std"
) )
// inflightHandler is the fixed `handler` label on // inflightHandler is the fixed `handler` label on
@@ -151,17 +149,17 @@ func (r boundedLabelRecorder) AddInflightRequests(
var _ httpmetrics.Recorder = boundedLabelRecorder{} var _ httpmetrics.Recorder = boundedLabelRecorder{}
// Metrics returns middleware that records Prometheus HTTP metrics on // Metrics returns middleware that records Prometheus HTTP metrics
// the default registry, which is the one the /metrics route gathers. // with the Middleware's one recorder, which New builds on the registry
// the /metrics route serves and NewForTest on a registry of its own.
// Every call reuses that recorder, so any number of routers can
// install it.
func (s *Middleware) Metrics() func(http.Handler) http.Handler { func (s *Middleware) Metrics() func(http.Handler) http.Handler {
return metricsMiddleware( return metricsMiddleware(s.metricsRecorder)
prommetrics.NewRecorder(prommetrics.Config{}),
)
} }
// metricsMiddleware builds the recording middleware against a given // metricsMiddleware builds the recording middleware against a given
// recorder, so tests can gather from a registry of their own instead // recorder, so tests can gather from a registry of their own.
// of the process-wide default.
func metricsMiddleware( func metricsMiddleware(
rec httpmetrics.Recorder, rec httpmetrics.Recorder,
) func(http.Handler) http.Handler { ) func(http.Handler) http.Handler {
@@ -170,13 +168,72 @@ func metricsMiddleware(
}) })
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
// The handler id is unmatchedRoute rather than "" so that return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// the client-chosen URL path never enters the metrics mw := &metricsResponseWriter{
// pipeline at all: an empty id is the library's signal to ResponseWriter: w,
// substitute it. boundedLabelRecorder overwrites this value request: r,
// on every observation, so it is reachable only if that statusCode: http.StatusOK,
// decorator is removed — in which case the metrics collapse }
// to one series instead of leaking again.
return std.Handler(unmatchedRoute, mdlw, next) // The handler id is unmatchedRoute rather than "" so
// that the client-chosen URL path never enters the
// metrics pipeline at all: an empty id is the library's
// signal to substitute it. boundedLabelRecorder
// overwrites this value on every observation, so it is
// reachable only if that decorator is removed — in which
// case the metrics collapse to one series instead of
// leaking again.
mdlw.Measure(unmatchedRoute, mw, func() {
next.ServeHTTP(mw, r)
})
})
} }
} }
// metricsResponseWriter records the status code and body size of a
// response, and hands them with the request to go-http-metrics'
// Measure as its Reporter.
//
// It stands in for the library's std.Handler, whose writer has no
// Unwrap: behind it, http.ResponseController cannot reach net/http's
// own writer, so a handler's write deadline fails with metrics on.
type metricsResponseWriter struct {
http.ResponseWriter
request *http.Request
statusCode int
bytesWritten int64
}
func (w *metricsResponseWriter) WriteHeader(code int) {
w.statusCode = code
w.ResponseWriter.WriteHeader(code)
}
func (w *metricsResponseWriter) Write(b []byte) (int, error) {
w.bytesWritten += int64(len(b))
//nolint:wrapcheck // Pass the writer's own error through unchanged.
return w.ResponseWriter.Write(b)
}
// Unwrap lets http.ResponseController reach the writer underneath, so
// a handler can still flush or set a write deadline with metrics on.
func (w *metricsResponseWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
func (w *metricsResponseWriter) Method() string { return w.request.Method }
func (w *metricsResponseWriter) Context() context.Context {
return w.request.Context()
}
func (w *metricsResponseWriter) URLPath() string { return w.request.URL.Path }
func (w *metricsResponseWriter) StatusCode() int { return w.statusCode }
func (w *metricsResponseWriter) BytesWritten() int64 { return w.bytesWritten }
var _ ghmm.Reporter = (*metricsResponseWriter)(nil)
+28 -3
View File
@@ -57,9 +57,8 @@ const (
// Server.setupWebhookRoutes inside it. That ordering is the whole // Server.setupWebhookRoutes inside it. That ordering is the whole
// defect, so a test that flattens it would prove nothing. // defect, so a test that flattens it would prove nothing.
// //
// The recorder writes to a registry of the test's own rather than the // The recorder writes to a registry of the test's own, so each test
// process-wide default one, so each test observes only its own // observes only its own traffic.
// traffic.
func metricsTestRouter( func metricsTestRouter(
t *testing.T, t *testing.T,
receiverLimit int, receiverLimit int,
@@ -455,3 +454,29 @@ func TestMetrics_StatusAndSizeStillRecorded(t *testing.T) {
"the interceptor must still count written bytes", "the interceptor must still count written bytes",
) )
} }
// TestMetrics_WorksOnNewForTestMiddleware pins that a Middleware built
// by NewForTest has a recorder of its own: its Metrics() serves a
// request instead of panicking, and a second one does not collide
// with the first.
func TestMetrics_WorksOnNewForTestMiddleware(t *testing.T) {
t.Parallel()
log := slog.New(slog.DiscardHandler)
cfg := &config.Config{Environment: "prod"}
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(okBody))
})
for range 2 {
h := middleware.NewForTest(log, cfg, nil).Metrics()(ok)
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, okRoute, nil,
)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
}
+70 -14
View File
@@ -3,6 +3,7 @@
package middleware package middleware
import ( import (
"context"
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
@@ -14,6 +15,9 @@ import (
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/go-chi/chi/middleware" "github.com/go-chi/chi/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
"github.com/prometheus/client_golang/prometheus"
httpmetrics "github.com/slok/go-http-metrics/metrics"
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
@@ -66,18 +70,19 @@ const (
// url, useragent, referer 3*(512+11) = 1569 // url, useragent, referer 3*(512+11) = 1569
// request_id 128+11 = 139 // request_id 128+11 = 139
// method 32+11 = 43 // method 32+11 = 43
// fixed portion = 336 // fixed portion = 405
// ---- // ----
// 2087 // 2156
// //
// The 512 is logfield.MaxBytes; the 11 is the truncation marker, // The 512 is logfield.MaxBytes; the 11 is the truncation marker,
// charged on top of each budget rather than inside it. // charged on top of each budget rather than inside it.
// //
// The fixed portion is the JSON punctuation, the field names, the // The fixed portion is the JSON punctuation, the field names, the
// level and the message, both timestamps at their longest, an IPv6 // level and the message, both timestamps at their longest, remoteIP
// remoteIP with a zone, a three-digit status and a full-width int64 // and clientIP each charged as an IPv6 address with a zone, a
// latency. Stated at 2560 so the figure carries headroom rather // three-digit status and a full-width int64 latency. Stated at 2560
// than sitting on the arithmetic. // so the figure carries headroom rather than sitting on the
// arithmetic.
// //
// The tty text handler in internal/logger is covered by the same // The tty text handler in internal/logger is covered by the same
// figure. logfield.EncodedBytes charges every rune at least what // figure. logfield.EncodedBytes charges every rune at least what
@@ -85,8 +90,8 @@ const (
// bytes strconv.Quote spends on a non-printable rune at or above // bytes strconv.Quote spends on a non-printable rune at or above
// U+10000, which is four more than the JSON handler ever spends — // U+10000, which is four more than the JSON handler ever spends —
// so each budget bounds the encoded field under either handler. // so each budget bounds the encoded field under either handler.
// The text handler's fixed portion is 286, the smaller of the two, // The text handler's fixed portion is 351, the smaller of the two,
// which puts its worst case at 2037. // which puts its worst case at 2102.
// //
// It is also the ceiling on every OTHER line this service writes // It is also the ceiling on every OTHER line this service writes
// THROUGH SLOG that carries text an UNAUTHENTICATED client // THROUGH SLOG that carries text an UNAUTHENTICATED client
@@ -149,10 +154,11 @@ const (
type MiddlewareParams struct { type MiddlewareParams struct {
fx.In fx.In
Logger *logger.Logger Logger *logger.Logger
Globals *globals.Globals Globals *globals.Globals
Config *config.Config Config *config.Config
Session *session.Session Session *session.Session
Registry *prometheus.Registry
} }
// Middleware provides HTTP middleware for logging, CORS, auth, and // Middleware provides HTTP middleware for logging, CORS, auth, and
@@ -162,6 +168,14 @@ type Middleware struct {
params *MiddlewareParams params *MiddlewareParams
session *session.Session session *session.Session
// metricsRecorder records the inbound HTTP metrics. New builds
// it on the registry /metrics serves, NewForTest on a registry
// of its own. Either way it is built once per Middleware and
// Metrics reuses it, because building it registers its
// collectors, and a second registration on the same registry
// panics.
metricsRecorder httpmetrics.Recorder
// loginGuard counts failed credential verifications and bounds // loginGuard counts failed credential verifications and bounds
// concurrent password hashing. It is built on first use so that // concurrent password hashing. It is built on first use so that
// every construction path gets one; see guard(). // every construction path gets one; see guard().
@@ -180,6 +194,9 @@ func New(
s.params = &params s.params = &params
s.log = params.Logger.Get() s.log = params.Logger.Get()
s.session = params.Session s.session = params.Session
s.metricsRecorder = prommetrics.NewRecorder(
prommetrics.Config{Registry: params.Registry},
)
return s, nil return s, nil
} }
@@ -200,6 +217,28 @@ func ipFromHostPort(hp string) string {
return h return h
} }
// RemoteIP returns the address of the connecting peer, without its
// port. Behind a reverse proxy it is the proxy. Every log line that
// names the client logs it as remoteIP, next to clientIP.
func RemoteIP(r *http.Request) string {
return ipFromHostPort(r.RemoteAddr)
}
// clientIPKey is the request context key under which Logging stores
// the value ClientIP returns.
type clientIPKey struct{}
// ClientIP returns the address the request is attributed to, which
// Logging works out once per request with clientAddr in ratelimit.go
// and logs as clientIP. The other lines that name the client read it
// from here, so all of them agree. It is empty for a request Logging
// has not seen.
func ClientIP(r *http.Request) string {
ip, _ := r.Context().Value(clientIPKey{}).(string)
return ip
}
type loggingResponseWriter struct { type loggingResponseWriter struct {
http.ResponseWriter http.ResponseWriter
@@ -218,6 +257,13 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.ResponseWriter.WriteHeader(code) lrw.ResponseWriter.WriteHeader(code)
} }
// Unwrap lets http.ResponseController reach the writer underneath, so
// a handler can still flush or set a write deadline through the access
// log.
func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
return lrw.ResponseWriter
}
// concreteLogURL renders the request's own URL for the access log // concreteLogURL renders the request's own URL for the access log
// branches that keep it, with the query string replaced by a fixed // branches that keep it, with the query string replaced by a fixed
// marker. // marker.
@@ -294,6 +340,13 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
lrw := newLoggingResponseWriter(w) lrw := newLoggingResponseWriter(w)
ctx := r.Context() ctx := r.Context()
// When RemoteAddr is not an address, the peer's own
// text is all the request can be attributed to.
clientIP := RemoteIP(r)
if addr, ok := s.clientAddr(r); ok {
clientIP = addr.String()
}
defer func() { defer func() {
latency := time.Since(start) latency := time.Since(start)
requestID := "" requestID := ""
@@ -328,13 +381,16 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
r.Referer(), logfield.MaxBytes, r.Referer(), logfield.MaxBytes,
), ),
"proto", r.Proto, "proto", r.Proto,
"remoteIP", ipFromHostPort(r.RemoteAddr), "remoteIP", RemoteIP(r),
"clientIP", clientIP,
"status", lrw.statusCode, "status", lrw.statusCode,
"latency_ms", latency.Milliseconds(), "latency_ms", latency.Milliseconds(),
) )
}() }()
next.ServeHTTP(lrw, r) next.ServeHTTP(lrw, r.WithContext(
context.WithValue(ctx, clientIPKey{}, clientIP),
))
}) })
} }
} }
+2 -11
View File
@@ -12,7 +12,6 @@ import (
"testing" "testing"
"time" "time"
"github.com/gorilla/sessions"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
@@ -78,14 +77,7 @@ func newTestSessionManager(
key[i] = byte(i) key[i] = byte(i)
} }
store := sessions.NewCookieStore(key) store := session.NewStore(key)
store.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7,
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteLaxMode,
}
var now func() time.Time var now func() time.Time
@@ -931,8 +923,7 @@ func metricsAuthMiddleware(
} }
key := make([]byte, testKeySize) key := make([]byte, testKeySize)
store := sessions.NewCookieStore(key) store := session.NewStore(key)
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
sessManager := session.NewForTest(store, cfg, log, key, nil) sessManager := session.NewForTest(store, cfg, log, key, nil)
+41 -18
View File
@@ -202,44 +202,61 @@ func (m *Middleware) forwardedClientAddr(
} }
// rateLimitKey is the client identity every rate limiter in this // rateLimitKey is the client identity every rate limiter in this
// package buckets on. Forwarded headers are honoured only when the // package buckets on: the address clientAddr attributes the request
// direct peer (RemoteAddr) is inside the configured trusted-proxy // to, reduced to a bucket by bucketKey — full address for IPv4, /64
// set; otherwise the peer address itself is the key. Without that // prefix for IPv6.
// gate any client could mint a fresh bucket per request, or starve
// another client's bucket, by picking an X-Forwarded-For value —
// which makes every limit here decorative against a deliberate
// attacker.
//
// The address that identifies the client is then reduced to a bucket
// by bucketKey: full address for IPv4, /64 prefix for IPv6.
func (m *Middleware) rateLimitKey(r *http.Request) (string, error) { func (m *Middleware) rateLimitKey(r *http.Request) (string, error) {
return m.clientKey(r), nil return m.clientKey(r), nil
} }
// clientKey computes the bucket key described on rateLimitKey. // clientKey computes the bucket key described on rateLimitKey.
func (m *Middleware) clientKey(r *http.Request) string { func (m *Middleware) clientKey(r *http.Request) string {
peer, err := netip.ParseAddr(ipFromHostPort(r.RemoteAddr)) addr, ok := m.clientAddr(r)
if err != nil { if !ok {
// Not an address we can reason about; key on the raw // Not an address we can reason about; key on the raw
// value, the most specific identity left. Distinct // value, the most specific identity left. Distinct
// RemoteAddr values stay in distinct buckets, so this // RemoteAddr values stay in distinct buckets, so this
// path cannot silently collapse unrelated clients // path cannot silently collapse unrelated clients
// together. On a Unix-socket listener every peer // together. On a Unix-socket listener every peer
// carries the same RemoteAddr and so shares one bucket, // carries the same RemoteAddr and so shares one bucket,
// which is the fail-closed direction. // which is the fail-closed direction. An empty RemoteAddr
// is a different case, which net/http never produces for
// a TCP listener and only a hand-built request carries,
// but it fails closed the same way: every such request
// shares the one bucket keyed on the empty string.
return r.RemoteAddr return r.RemoteAddr
} }
return bucketKey(addr)
}
// clientAddr is the address a request is attributed to. The rate
// limiters key on it and the logs name it as clientIP.
//
// Forwarded headers are honoured only when the direct peer
// (RemoteAddr) is inside the configured trusted-proxy set; otherwise
// the peer address itself is the client. Without that gate any client
// could mint a fresh bucket per request, or starve another client's
// bucket, by picking an X-Forwarded-For value — which makes every
// limit here decorative against a deliberate attacker.
//
// ok is false when RemoteAddr is not an address at all.
func (m *Middleware) clientAddr(r *http.Request) (netip.Addr, bool) {
peer, err := netip.ParseAddr(ipFromHostPort(r.RemoteAddr))
if err != nil {
return netip.Addr{}, false
}
peer = normalizeAddr(peer) peer = normalizeAddr(peer)
if !m.isTrustedProxy(peer) { if !m.isTrustedProxy(peer) {
return bucketKey(peer) return peer, true
} }
if addr, ok := m.forwardedClientAddr(r); ok { if addr, ok := m.forwardedClientAddr(r); ok {
return bucketKey(addr) return addr, true
} }
return bucketKey(peer) return peer, true
} }
// tooManyRequests returns the 429 handler used by the // tooManyRequests returns the 429 handler used by the
@@ -262,6 +279,8 @@ func (m *Middleware) tooManyRequests(
"path", logfield.Truncate( "path", logfield.Truncate(
r.URL.Path, logfield.MaxBytes, r.URL.Path, logfield.MaxBytes,
), ),
"remoteIP", RemoteIP(r),
"clientIP", ClientIP(r),
) )
http.Error(w, responseMessage, http.StatusTooManyRequests) http.Error(w, responseMessage, http.StatusTooManyRequests)
} }
@@ -286,8 +305,12 @@ func (m *Middleware) tooManyRequests(
func (m *Middleware) floodTooManyRequests( func (m *Middleware) floodTooManyRequests(
logMessage, responseMessage string, logMessage, responseMessage string,
) http.HandlerFunc { ) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
m.log.Debug(logMessage) m.log.Debug(
logMessage,
"remoteIP", RemoteIP(r),
"clientIP", ClientIP(r),
)
http.Error(w, responseMessage, http.StatusTooManyRequests) http.Error(w, responseMessage, http.StatusTooManyRequests)
} }
} }
+17
View File
@@ -1012,6 +1012,23 @@ func TestRateLimitKey_UnparseablePeerKeepsDistinctBuckets(
) )
} }
// TestRateLimitKey_EmptyPeerSharesOneBucket pins what the fallback
// does with an empty RemoteAddr: it keys on the empty string, so every
// such request shares one bucket. That is the fail-closed direction
// and is kept on purpose; only a hand-built request carries an empty
// RemoteAddr.
func TestRateLimitKey_EmptyPeerSharesOneBucket(t *testing.T) {
t.Parallel()
m := rateLimitMiddleware(t, &config.Config{})
assert.Empty(
t, clientKeyFor(t, m, ""),
"every peer with an empty RemoteAddr must key on the "+
"empty string and so share one bucket",
)
}
// TestPostRateLimit_IPv6SharesBucketWithinSlash64 is the behavioural // TestPostRateLimit_IPv6SharesBucketWithinSlash64 is the behavioural
// half, and the regression test for the bypass itself: a client that // half, and the regression test for the bypass itself: a client that
// rotates source addresses inside its own routed /64 must stay in one // rotates source addresses inside its own routed /64 must stay in one
+3 -5
View File
@@ -627,11 +627,9 @@ func TestRecovererIgnoresANonPanickingHandler(t *testing.T) {
// net/http's own writer from http.ResponseController, so a handler // net/http's own writer from http.ResponseController, so a handler
// that flushes or sets a deadline starts failing. // that flushes or sets a deadline starts failing.
// //
// The recoverer is the only middleware in the chain here. The access // The recoverer is the only middleware in the chain here;
// logger's own wrapper does not implement Unwrap, so a chain // TestResponseControllerThroughProductionRouter in internal/server
// containing it fails this regardless of what the recoverer does; // covers the shipped chain.
// what is being pinned is that the recoverer adds no such opacity of
// its own.
func TestRecovererKeepsResponseControllerWorking(t *testing.T) { func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
t.Parallel() t.Parallel()
+8
View File
@@ -3,12 +3,17 @@ package middleware
import ( import (
"log/slog" "log/slog"
"github.com/prometheus/client_golang/prometheus"
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
) )
// NewForTest creates a Middleware with the minimum dependencies // NewForTest creates a Middleware with the minimum dependencies
// needed for testing. This bypasses the fx lifecycle. // needed for testing. This bypasses the fx lifecycle.
//
// Its metrics recorder writes to a fresh registry of its own, so
// Metrics() works on it and two of them never collide.
func NewForTest( func NewForTest(
log *slog.Logger, log *slog.Logger,
cfg *config.Config, cfg *config.Config,
@@ -20,5 +25,8 @@ func NewForTest(
Config: cfg, Config: cfg,
}, },
session: sess, session: sess,
metricsRecorder: prommetrics.NewRecorder(
prommetrics.Config{Registry: prometheus.NewRegistry()},
),
} }
} }
+7
View File
@@ -24,6 +24,7 @@ import (
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/resetpw" "sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
@@ -157,6 +158,10 @@ func newServerApp(
app := fxtest.New( app := fxtest.New(
t, t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned.
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -169,6 +174,8 @@ func newServerApp(
session.New, session.New,
func() delivery.Notifier { return &noopNotifier{} }, func() delivery.Notifier { return &noopNotifier{} },
func() delivery.Archives { return &noopArchives{} }, func() delivery.Archives { return &noopArchives{} },
metrics.NewRegistry,
metrics.New,
middleware.New, middleware.New,
delivery.NewGuard, delivery.NewGuard,
handlers.New, handlers.New,
+391
View File
@@ -0,0 +1,391 @@
//go:build browser
// This test needs a headless browser, so it is built only with the
// browser build tag: `make test` leaves it out, and `make test-browser`
// runs it in the browser image that Dockerfile.browser pins.
package server_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
)
const (
// browserTimeout bounds everything one test does in the browser.
browserTimeout = 60 * time.Second
// settleTimeout bounds the wait for an element to show or hide.
settleTimeout = 5 * time.Second
// The window size of a phone, narrow enough that the pages show
// the mobile menu button instead of the navigation links.
phoneWidth = 390
phoneHeight = 844
)
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
// event log in a headless browser, served by the real router and so
// under the real Content-Security-Policy, and checks that the pages'
// Alpine.js directives work.
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
t.Parallel()
ctx, problems := startBrowser(t)
env := newTestEnv(t)
srv := httptest.NewServer(env.router)
t.Cleanup(srv.Close)
userID, _ := env.seedUser(t, "browser", "browser-password")
webhook := env.seedWebhook(t, userID)
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
target := env.seedTarget(t, webhook.ID)
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
webhookDB, err := env.dbMgr.GetDB(webhook.ID)
require.NoError(t, err)
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
&database.DeliveryResult{
DeliveryID: dlv.ID,
AttemptNum: 1,
StatusCode: http.StatusBadGateway,
},
).Error)
require.NoError(t, chromedp.Run(
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
))
page := srv.URL + "/hook/" + webhook.ID
checkAddForms(ctx, t, page)
checkTargetType(ctx, t, page+"/events")
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
checkMobileMenu(ctx, t, page)
assert.Empty(t, problems(), "the browser reported problems")
}
// startBrowser starts a headless browser for one test. It returns the
// context that drives it, and a function listing what the browser
// reported going wrong on its pages: console warnings and errors,
// which is how Alpine.js reports an expression it cannot run; uncaught
// exceptions; and every entry in the browser's own security log, which
// is where it reports each script, style, image or request the
// Content-Security-Policy refused.
//
// The browser library finds the browser on PATH. Without one the first
// chromedp.Run fails, and with it the test.
func startBrowser(t *testing.T) (context.Context, func() []string) {
t.Helper()
allocCtx, cancelAlloc := chromedp.NewExecAllocator(
t.Context(),
append(
chromedp.DefaultExecAllocatorOptions[:],
// Dockerfile.browser runs the test as root, where the
// browser's sandbox cannot start.
chromedp.NoSandbox,
)...,
)
t.Cleanup(cancelAlloc)
ctx, cancel := chromedp.NewContext(allocCtx)
t.Cleanup(cancel)
ctx, cancelTimeout := context.WithTimeout(ctx, browserTimeout)
t.Cleanup(cancelTimeout)
var (
mu sync.Mutex
problems []string
)
chromedp.ListenTarget(ctx, func(ev any) {
var problem string
switch ev := ev.(type) {
case *runtime.EventConsoleAPICalled:
if ev.Type != runtime.APITypeWarning &&
ev.Type != runtime.APITypeError {
return
}
args := make([]string, 0, len(ev.Args))
for _, arg := range ev.Args {
args = append(args, string(arg.Value))
}
problem = strings.Join(args, " ")
case *runtime.EventExceptionThrown:
problem = ev.ExceptionDetails.Error()
case *log.EventEntryAdded:
if ev.Entry.Source != log.SourceSecurity {
return
}
problem = ev.Entry.Text
default:
return
}
mu.Lock()
defer mu.Unlock()
problems = append(problems, problem)
})
return ctx, func() []string {
mu.Lock()
defer mu.Unlock()
return slices.Clone(problems)
}
}
// setCookies gives the browser the cookies for the server at base.
func setCookies(base string, cookies []*http.Cookie) chromedp.ActionFunc {
return chromedp.ActionFunc(func(ctx context.Context) error {
for _, c := range cookies {
err := network.SetCookie(c.Name, c.Value).
WithURL(base).
Do(ctx)
if err != nil {
return fmt.Errorf("set cookie %s: %w", c.Name, err)
}
}
return nil
})
}
// loadPage opens url and waits for Alpine.js to start, which it does
// by removing every x-cloak attribute. Until then x-cloak hides the
// elements Alpine would hide, so a check made earlier proves nothing.
func loadPage(url string) chromedp.Tasks {
return chromedp.Tasks{
chromedp.Navigate(url),
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
}
}
// shown waits up to settleTimeout for the elements matching a CSS
// selector or an XPath expression to be rendered, and reports whether
// they were. The wait is needed because Alpine.js shows an element on
// the next animation frame, not at once.
func shown(ctx context.Context, selector string) bool {
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
defer cancel()
return chromedp.Run(
ctx, chromedp.WaitVisible(selector, chromedp.BySearch),
) == nil
}
// hidden is shown's opposite: it waits for the elements to be hidden.
func hidden(ctx context.Context, selector string) bool {
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
defer cancel()
return chromedp.Run(
ctx, chromedp.WaitNotVisible(selector, chromedp.BySearch),
) == nil
}
// click clicks the element matching an XPath expression.
func click(ctx context.Context, t *testing.T, xpath string) {
t.Helper()
require.NoError(t, chromedp.Run(
ctx, chromedp.Click(xpath, chromedp.BySearch),
))
}
// checkAddForms loads a webhook page and checks that each section's add
// form stays hidden until the Add button beside its heading is clicked.
func checkAddForms(ctx context.Context, t *testing.T, url string) {
t.Helper()
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
sections := []struct{ heading, form string }{
{"Entrypoints", `form[action$="/entrypoints"]`},
{"Targets", `form[action$="/targets"]`},
}
for _, s := range sections {
assert.Truef(
t, hidden(ctx, s.form),
"%s: the add form shows before Add is clicked", s.heading,
)
click(ctx, t, `//h2[text()="`+s.heading+
`"]/following-sibling::button`)
assert.Truef(
t, shown(ctx, s.form),
"%s: the add form stays hidden when Add is clicked", s.heading,
)
}
}
// checkTargetType chooses Slack in the open add target form and checks
// what the form would then submit: one url field, the Slack one, and
// not the HTTP url, headers or timeout, which are hidden and disabled.
//
// It then opens the page at elsewhere and goes back. The browser loads
// the webhook page again and restores the form as it was left, Slack
// chosen, without a change event; the form must again show and submit
// Slack's fields, not the HTTP ones.
func checkTargetType(ctx context.Context, t *testing.T, elsewhere string) {
t.Helper()
const (
chooseSlack = `(() => {
const type = document.querySelector('select[name="type"]');
type.value = "slack";
type.dispatchEvent(new Event("change"));
})()`
chosen = `document.querySelector('select[name="type"]').value`
howLoaded = `performance.getEntriesByType("navigation")[0].type`
submitted = `[...new FormData(
document.querySelector('form[action$="/targets"]')).keys()]`
slackURL = `input[placeholder^="https://hooks.slack.com/"]`
httpURL = `input[placeholder="https://example.com/webhook"]`
)
slackFields := strings.Fields("csrf_token name type max_retries url")
var fields []string
require.NoError(t, chromedp.Run(
ctx,
chromedp.Evaluate(chooseSlack, nil),
chromedp.Evaluate(submitted, &fields),
))
assert.Equal(
t, slackFields, fields,
"with Slack chosen, the HTTP fields must not be submitted",
)
var loaded, restored string
// Going back waits for the load event, after which the browser has
// restored the form.
require.NoError(t, chromedp.Run(
ctx,
loadPage(elsewhere),
chromedp.NavigateBack(),
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
chromedp.Evaluate(howLoaded, &loaded),
chromedp.Evaluate(chosen, &restored),
))
// A page the browser kept in memory and showed again as it was
// would prove nothing here.
require.Equal(
t, "back_forward", loaded,
"going back, the browser did not load the page again",
)
require.Equal(
t, "slack", restored,
"going back, the browser did not restore the chosen type",
)
click(ctx, t, `//h2[text()="Targets"]/following-sibling::button`)
assert.True(t, shown(ctx, slackURL),
"going back with Slack chosen, the Slack fields are not shown")
assert.True(t, hidden(ctx, httpURL),
"going back with Slack chosen, the HTTP fields are shown")
require.NoError(t, chromedp.Run(
ctx, chromedp.Evaluate(submitted, &fields),
))
assert.Equal(
t, slackFields, fields,
"going back with Slack chosen, the HTTP fields must not be submitted",
)
}
// checkEventLog loads the event log and checks that clicking an event's
// row expands it, that in there clicking its delivery shows the
// delivery's attempts and clicking again hides them, and that clicking
// the event's row again collapses it.
func checkEventLog(
ctx context.Context, t *testing.T, url, eventID, targetName string,
) {
t.Helper()
// The event's row shows its ID, and its Resubmit form is in the part
// that expands. The delivery's row there shows the target's name.
eventRow := `//span[text()="` + eventID + `"]`
expanded := `form[action$="/resubmit"]`
deliveryRow := `//span[text()="` + targetName + `"]`
attempt := `//span[text()="Attempt 1"]`
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
click(ctx, t, eventRow)
assert.True(t, shown(ctx, expanded), "clicking the event does not expand it")
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
click(ctx, t, deliveryRow)
assert.True(t, shown(ctx, attempt),
"clicking the delivery does not show its attempts")
click(ctx, t, deliveryRow)
assert.True(t, hidden(ctx, attempt),
"clicking the delivery again does not hide its attempts")
click(ctx, t, eventRow)
assert.True(t, hidden(ctx, expanded),
"clicking the event again does not collapse it")
}
// checkMobileMenu loads a page in a phone-sized window and checks that
// the menu button opens and closes the mobile menu.
func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
t.Helper()
// The menu button is the only button directly in the navigation
// bar's top row. Profile is a link only the mobile menu has.
button := `//nav/div/button`
menu := `//nav//a[text()="Profile"]`
require.NoError(t, chromedp.Run(
ctx,
chromedp.EmulateViewport(phoneWidth, phoneHeight),
loadPage(url),
))
assert.True(t, hidden(ctx, menu), "the mobile menu starts open")
click(ctx, t, button)
assert.True(t, shown(ctx, menu), "the menu button does not open the menu")
click(ctx, t, button)
assert.True(t, hidden(ctx, menu), "the menu button does not close the menu")
}
+16
View File
@@ -83,6 +83,22 @@ func TestErrorPage_DeletedTarget(t *testing.T) {
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks) assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
} }
// TestErrorPage_ShowsNoNotice pins that a notice code in the URL of a
// page that fails is not shown above the error.
func TestErrorPage_ShowsNoNotice(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
w := env.get("/hook/no-such-webhook?notice=webhook-saved", cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
assert.NotContains(t, w.Body.String(), "Webhook saved.")
}
func TestErrorPage_UnknownPath(t *testing.T) { func TestErrorPage_UnknownPath(t *testing.T) {
t.Parallel() t.Parallel()
+109
View File
@@ -0,0 +1,109 @@
package server_test
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/server"
)
// TestResponseControllerThroughProductionRouter sets a write deadline
// and flushes through http.ResponseController, behind the shipped
// router and over a real connection, and checks that both reach
// net/http's own writer.
//
// Every middleware that wraps the writer has to let them through with
// an Unwrap method. One that does not makes the call return
// http.ErrNotSupported, or, if it has a Flush of its own that cannot
// reach further in, makes the flush silently do nothing; either way
// the handler that trips over it is far from the cause.
//
// It runs once with the defaults and once with metrics and Sentry on,
// because those two add middleware to the chain, and through both the
// global middleware and an admin page route group, which adds its own.
func TestResponseControllerThroughProductionRouter(t *testing.T) {
t.Parallel()
// Without /metrics credentials, metricsConfig is the default
// Config.
cases := []struct {
name string
username, password string
sentryEnabled bool
}{
{name: "defaults"},
{
name: "metrics and Sentry on",
username: metricsUser, password: metricsAuthValue,
sentryEnabled: true,
},
}
// The probe answers with what each call returned.
probe := func(w http.ResponseWriter, _ *http.Request) {
rc := http.NewResponseController(w)
deadlineErr := rc.SetWriteDeadline(time.Now().Add(time.Minute))
flushErr := rc.Flush()
_, _ = fmt.Fprintf(
w, "deadline: %v, flush: %v", deadlineErr, flushErr,
)
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
env := newTestEnvWithConfig(
t, metricsConfig(t, tc.username, tc.password),
)
routers := map[string]http.Handler{
server.ProbePattern: server.NewRouterWithProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd,
tc.sentryEnabled, probe,
),
server.PageProbePattern: server.NewRouterWithPageProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd,
tc.sentryEnabled, probe,
),
}
for path, router := range routers {
srv := httptest.NewServer(router)
t.Cleanup(srv.Close)
req, err := http.NewRequestWithContext(
t.Context(), http.MethodGet, srv.URL+path, nil,
)
require.NoError(t, err)
resp, err := srv.Client().Do(req)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(
t, "deadline: <nil>, flush: <nil>", string(body), path,
)
// A response the server holds until the handler returns
// goes out with a Content-Length; one flushed while the
// handler is still running goes out in chunks.
assert.Equal(
t, []string{"chunked"}, resp.TransferEncoding,
"%s: the flush must reach the client", path,
)
}
})
}
}
+31 -11
View File
@@ -7,7 +7,6 @@ import (
sentryhttp "github.com/getsentry/sentry-go/http" sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/go-chi/chi/middleware" "github.com/go-chi/chi/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
"sneak.berlin/go/webhooker/static" "sneak.berlin/go/webhooker/static"
) )
@@ -15,10 +14,11 @@ import (
// bytes) for form POST endpoints. 1 MB is generous for any form // bytes) for form POST endpoints. 1 MB is generous for any form
// submission while preventing abuse from oversized payloads. // submission while preventing abuse from oversized payloads.
// //
// The four admin page route groups below (/pages, /user/{username}, // The five admin page route groups below (/pages, /user/{username},
// /hooks and /hook/{sourceID}) install MaxBodySize(maxFormBodySize) // /settings, /hooks and /hook/{sourceID}) install
// right after their recoverer and error reporting, ahead of both CSRF // MaxBodySize(maxFormBodySize) right after their recoverer and error
// and RequireAuth. Both orderings are deliberate. // reporting, ahead of both CSRF and RequireAuth. Both orderings are
// deliberate.
// //
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to // Ahead of CSRF because gorilla/csrf parses the form. The cap has to
// be installed before anything reads the body, or the parse runs // be installed before anything reads the body, or the parse runs
@@ -149,17 +149,13 @@ func (s *Server) setupRoutes() {
if s.params.Config.MetricsAuthEnabled() { if s.params.Config.MetricsAuthEnabled() {
s.router.Group(func(r chi.Router) { s.router.Group(func(r chi.Router) {
r.Use(s.mw.MetricsAuth()) r.Use(s.mw.MetricsAuth())
r.Get( r.Get("/metrics", s.h.HandleMetrics())
"/metrics",
http.HandlerFunc(
promhttp.Handler().ServeHTTP,
),
)
}) })
} }
s.setupPageRoutes() s.setupPageRoutes()
s.setupUserRoutes() s.setupUserRoutes()
s.setupSettingsRoutes()
s.setupSourceRoutes() s.setupSourceRoutes()
s.setupWebhookRoutes() s.setupWebhookRoutes()
} }
@@ -207,6 +203,24 @@ func (s *Server) setupUserRoutes() {
}) })
} }
// setupSettingsRoutes serves the Settings page. It is GET only:
// configuration comes from the environment and nothing here changes
// it.
func (s *Server) setupSettingsRoutes() {
s.router.Route("/settings", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSettings())
})
}
func (s *Server) setupSourceRoutes() { func (s *Server) setupSourceRoutes() {
s.router.Route("/hooks", func(r chi.Router) { s.router.Route("/hooks", func(r chi.Router) {
s.recoverPanics( s.recoverPanics(
@@ -310,6 +324,12 @@ func (s *Server) setupSourceRoutes() {
} }
func (s *Server) setupWebhookRoutes() { func (s *Server) setupWebhookRoutes() {
// No MaxBodySize here, unlike the page groups. The receiver's 1 MB
// body cap is in Handlers.readWebhookBody, because the handler
// owns the response a sender gets for an oversized body and
// MaxBodySize would change it. That cap is the only bound on this
// unauthenticated endpoint's body; TestReceiver_OversizeBodyRefused
// pins it.
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc( s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
"/h/{uuid}", "/h/{uuid}",
s.h.HandleWebhook(), s.h.HandleWebhook(),
+211 -34
View File
@@ -24,6 +24,7 @@ import (
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/server" "sneak.berlin/go/webhooker/internal/server"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
@@ -109,6 +110,10 @@ func newTestEnvWithConfig(
app := fxtest.New( app := fxtest.New(
t, t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned.
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -119,6 +124,8 @@ func newTestEnvWithConfig(
session.New, session.New,
func() delivery.Notifier { return &noopNotifier{} }, func() delivery.Notifier { return &noopNotifier{} },
func() delivery.Archives { return &noopArchives{} }, func() delivery.Archives { return &noopArchives{} },
metrics.NewRegistry,
metrics.New,
middleware.New, middleware.New,
delivery.NewGuard, delivery.NewGuard,
handlers.New, handlers.New,
@@ -266,6 +273,24 @@ func (e *testEnv) urlFrom(
return html.UnescapeString(match[1]) return html.UnescapeString(match[1])
} }
// requireNotice requires w to redirect to dest carrying the notice
// code, then renders that page and requires it to show text.
func (e *testEnv) requireNotice(
t *testing.T,
w *httptest.ResponseRecorder,
dest, code, text string,
cookies []*http.Cookie,
) {
t.Helper()
require.Equal(t, http.StatusSeeOther, w.Code)
require.Equal(t, dest+"?notice="+code, w.Header().Get("Location"))
page := e.get(w.Header().Get("Location"), cookies)
require.Equal(t, http.StatusOK, page.Code)
assert.Contains(t, page.Body.String(), text)
}
// authCookies forges an authenticated session for the given user. // authCookies forges an authenticated session for the given user.
func (e *testEnv) authCookies( func (e *testEnv) authCookies(
t *testing.T, t *testing.T,
@@ -744,6 +769,31 @@ func TestPagesLogin_ReturnsToTheRequestedPage(t *testing.T) {
assert.Equal(t, asked, w.Header().Get("Location")) assert.Equal(t, asked, w.Header().Get("Location"))
} }
// TestPagesLogout_SaysSignedOut signs out with the navbar's form and
// lands on the sign-in page, which says so.
func TestPagesLogout_SaysSignedOut(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "leaver", "somepassword")
token, cookies := env.csrfFrom(
t, "/hooks", env.authCookies(t, userID, "leaver"),
)
form := url.Values{}
form.Set("csrf_token", token)
w := env.post(
env.urlFrom(t, "/hooks", `action="(/pages/logout)"`, cookies),
form, cookies,
)
// The sign-in page is requested without the session cookie, which
// the logout told the browser to delete.
env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil)
}
// --- /user/{username} group --- // --- /user/{username} group ---
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged // TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
@@ -861,9 +911,9 @@ func TestHooks_ListAndNewWebhookForm(t *testing.T) {
require.NoError(t, require.NoError(t,
env.db.DB().Where("name = ?", "created").First(&created).Error, env.db.DB().Where("name = ?", "created").First(&created).Error,
) )
assert.Equal( env.requireNotice(
t, "/hook/"+created.ID, w.Header().Get("Location"), t, w, "/hook/"+created.ID, "webhook-created", "Webhook created.",
"creating a webhook should redirect to its page", cookies,
) )
} }
@@ -894,8 +944,7 @@ func TestHook_EditFormAndDelete(t *testing.T) {
env.urlFrom(t, editPage, `action="(/hook/[^/"]+/edit)"`, cookies), env.urlFrom(t, editPage, `action="(/hook/[^/"]+/edit)"`, cookies),
form, cookies, form, cookies,
) )
require.Equal(t, http.StatusSeeOther, w.Code) env.requireNotice(t, w, page, "webhook-saved", "Webhook saved.", cookies)
assert.Equal(t, page, w.Header().Get("Location"))
var edited database.Webhook var edited database.Webhook
@@ -909,16 +958,17 @@ func TestHook_EditFormAndDelete(t *testing.T) {
env.urlFrom(t, page, `action="(/hook/[^/"]+/delete)"`, cookies), env.urlFrom(t, page, `action="(/hook/[^/"]+/delete)"`, cookies),
form, cookies, form, cookies,
) )
require.Equal(t, http.StatusSeeOther, w.Code) env.requireNotice(
assert.Equal(t, "/hooks", w.Header().Get("Location")) t, w, "/hooks", "webhook-deleted", "Webhook deleted.", cookies,
)
assert.Equal( assert.Equal(
t, http.StatusNotFound, env.get(page, cookies).Code, t, http.StatusNotFound, env.get(page, cookies).Code,
"a deleted webhook's page should be gone", "a deleted webhook's page should be gone",
) )
} }
// TestHook_EntrypointActions adds, deactivates and deletes an // TestHook_EntrypointActions adds, deactivates, activates and deletes
// entrypoint with the forms on the webhook page, each submitted to // an entrypoint with the forms on the webhook page, each submitted to
// the action and with the token the page rendered. // the action and with the token the page rendered.
func TestHook_EntrypointActions(t *testing.T) { func TestHook_EntrypointActions(t *testing.T) {
t.Parallel() t.Parallel()
@@ -936,16 +986,19 @@ func TestHook_EntrypointActions(t *testing.T) {
form.Set("csrf_token", token) form.Set("csrf_token", token)
// submit posts the webhook page's form whose action pattern // submit posts the webhook page's form whose action pattern
// captures, and requires the redirect back to that page. // captures, and requires the redirect back to that page with the
submit := func(pattern string) { // notice code, and the page to show text.
submit := func(pattern, code, text string) {
t.Helper() t.Helper()
w := env.post(env.urlFrom(t, page, pattern, cookies), form, cookies) w := env.post(env.urlFrom(t, page, pattern, cookies), form, cookies)
require.Equal(t, http.StatusSeeOther, w.Code) env.requireNotice(t, w, page, code, text, cookies)
require.Equal(t, page, w.Header().Get("Location"))
} }
submit(`action="(/hook/[^/"]+/entrypoints)"`) toggle := `action="(/hook/[^/"]+/entrypoints/[^/"]+/toggle)"`
submit(`action="(/hook/[^/"]+/entrypoints)"`,
"entrypoint-added", "Entrypoint added.")
var added database.Entrypoint var added database.Entrypoint
@@ -954,7 +1007,7 @@ func TestHook_EntrypointActions(t *testing.T) {
) )
require.True(t, added.Active) require.True(t, added.Active)
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/toggle)"`) submit(toggle, "entrypoint-deactivated", "Entrypoint deactivated.")
var toggled database.Entrypoint var toggled database.Entrypoint
@@ -963,7 +1016,10 @@ func TestHook_EntrypointActions(t *testing.T) {
) )
assert.False(t, toggled.Active, "the toggle should deactivate it") assert.False(t, toggled.Active, "the toggle should deactivate it")
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/delete)"`) submit(toggle, "entrypoint-activated", "Entrypoint activated.")
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/delete)"`,
"entrypoint-deleted", "Entrypoint deleted.")
var left int64 var left int64
@@ -974,8 +1030,8 @@ func TestHook_EntrypointActions(t *testing.T) {
// TestHook_TargetActions adds a target with the form on the webhook // TestHook_TargetActions adds a target with the form on the webhook
// page, follows its Edit link to the target edit form and submits // page, follows its Edit link to the target edit form and submits
// it, then deactivates and deletes it, every URL and token taken from // it, then deactivates, activates and deletes it, every URL and token
// the rendered pages. // taken from the rendered pages.
func TestHook_TargetActions(t *testing.T) { func TestHook_TargetActions(t *testing.T) {
t.Parallel() t.Parallel()
@@ -990,27 +1046,29 @@ func TestHook_TargetActions(t *testing.T) {
// submit posts form, with the token, to the action pattern // submit posts form, with the token, to the action pattern
// captures on the page at from, and requires the redirect back to // captures on the page at from, and requires the redirect back to
// the webhook page. // the webhook page with the notice code, and that page to show
submit := func(from, pattern string, form url.Values) { // text.
submit := func(from, pattern string, form url.Values, code, text string) {
t.Helper() t.Helper()
form.Set("csrf_token", token) form.Set("csrf_token", token)
w := env.post(env.urlFrom(t, from, pattern, cookies), form, cookies) w := env.post(env.urlFrom(t, from, pattern, cookies), form, cookies)
require.Equal(t, http.StatusSeeOther, w.Code) env.requireNotice(t, w, page, code, text, cookies)
require.Equal(t, page, w.Header().Get("Location"))
} }
toggle := `action="(/hook/[^/"]+/targets/[^/"]+/toggle)"`
submit(page, `action="(/hook/[^/"]+/targets)"`, url.Values{ submit(page, `action="(/hook/[^/"]+/targets)"`, url.Values{
"name": {"added"}, "name": {"added"},
"type": {string(database.TargetTypeLog)}, "type": {string(database.TargetTypeLog)},
}) }, "target-added", "Target added.")
editPage := env.urlFrom( editPage := env.urlFrom(
t, page, `href="(/hook/[^/"]+/targets/[^/"]+/edit)"`, cookies, t, page, `href="(/hook/[^/"]+/targets/[^/"]+/edit)"`, cookies,
) )
submit(editPage, `action="(/hook/[^/"]+/targets/[^/"]+/edit)"`, submit(editPage, `action="(/hook/[^/"]+/targets/[^/"]+/edit)"`,
url.Values{"name": {"renamed"}}) url.Values{"name": {"renamed"}}, "target-saved", "Target saved.")
var edited database.Target var edited database.Target
@@ -1020,8 +1078,8 @@ func TestHook_TargetActions(t *testing.T) {
assert.Equal(t, "renamed", edited.Name) assert.Equal(t, "renamed", edited.Name)
require.True(t, edited.Active) require.True(t, edited.Active)
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/toggle)"`, submit(page, toggle, url.Values{},
url.Values{}) "target-deactivated", "Target deactivated.")
var toggled database.Target var toggled database.Target
@@ -1030,8 +1088,11 @@ func TestHook_TargetActions(t *testing.T) {
) )
assert.False(t, toggled.Active, "the toggle should deactivate it") assert.False(t, toggled.Active, "the toggle should deactivate it")
submit(page, toggle, url.Values{},
"target-activated", "Target activated.")
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/delete)"`, submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/delete)"`,
url.Values{}) url.Values{}, "target-deleted", "Target deleted.")
var left int64 var left int64
@@ -1067,9 +1128,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
env.urlFrom(t, logsPath, `action="(/hook/[^"]+/resubmit)"`, cookies), env.urlFrom(t, logsPath, `action="(/hook/[^"]+/resubmit)"`, cookies),
form, cookies, form, cookies,
) )
require.Equal(t, http.StatusSeeOther, w.Code) env.requireNotice(
assert.Equal( t, w, logsPath, "resubmit-no-targets",
t, logsPath+"?resubmit=no-targets", w.Header().Get("Location"), "this source has no active targets", cookies,
) )
webhookDB, err := env.dbMgr.GetDB(wh.ID) webhookDB, err := env.dbMgr.GetDB(wh.ID)
@@ -1305,10 +1366,8 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
html.UnescapeString(action[1]), form, cookies, html.UnescapeString(action[1]), form, cookies,
) )
require.Equal(t, http.StatusSeeOther, w.Code) env.requireNotice(
assert.Equal( t, w, logsPath, "replay-queued", "Replay queued:", cookies,
t, logsPath+"?replay=queued",
w.Header().Get("Location"),
) )
assert.Equal( assert.Equal(
t, int64(2), env.countDeliveries(t, wh.ID), t, int64(2), env.countDeliveries(t, wh.ID),
@@ -1369,6 +1428,53 @@ func TestReceiver_EntrypointURLIsRateLimited(t *testing.T) {
) )
} }
// TestReceiver_OversizeBodyRefused pins the receiver's 1 MB body
// cap, which lives in the handler rather than in a MaxBodySize
// middleware. A body exactly at the cap is accepted; one byte over is
// refused with the handler's own 413.
func TestReceiver_OversizeBodyRefused(t *testing.T) {
t.Parallel()
const bodyCap = 1 << 20 // 1 MB
env := newTestEnvWithConfig(t, &config.Config{
DataDir: t.TempDir(),
Environment: config.EnvironmentDev,
ReceiverRateLimit: 10,
})
userID, _ := env.seedUser(t, "receiver", "somepassword")
wh := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
&database.Entrypoint{
WebhookID: wh.ID,
Path: "0b7c3e5a-2d9f-4a61-8e4b-7c1d6f2a9e35",
Active: true,
},
).Error)
send := func(size int) *httptest.ResponseRecorder {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost,
"/h/0b7c3e5a-2d9f-4a61-8e4b-7c1d6f2a9e35",
strings.NewReader(strings.Repeat("a", size)),
)
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
return w
}
assert.Equal(
t, http.StatusOK, send(bodyCap).Code,
"a body exactly at the cap must be accepted",
)
w := send(bodyCap + 1)
assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
assert.Equal(t, "Request body too large\n", w.Body.String())
}
// metricsConfig is a Config differing from the routing default only // metricsConfig is a Config differing from the routing default only
// in the two /metrics credentials. // in the two /metrics credentials.
func metricsConfig( func metricsConfig(
@@ -1484,3 +1590,74 @@ func TestMetricsRouteUnmountedOnHalfSetConfig(t *testing.T) {
}) })
} }
} }
// TestTwoMetricsRoutersInOneProcess pins
// https://git.eeqj.de/sneak/webhooker/issues/227: a second
// metrics-enabled router in one process used to panic, because the
// HTTP metrics registered on Prometheus's global default registry.
// Two routers are built over separate dependency graphs and a third
// over the first graph again, and each must still serve the HTTP,
// delivery, Go runtime and process series, and the series counting
// scrapes of /metrics itself.
func TestTwoMetricsRoutersInOneProcess(t *testing.T) {
t.Parallel()
first := newTestEnvWithConfig(
t, metricsConfig(t, metricsUser, metricsAuthValue),
)
second := newTestEnvWithConfig(
t, metricsConfig(t, metricsUser, metricsAuthValue),
)
third := &testEnv{
router: server.NewRouterForTest(
first.log.Get(), first.cfg, first.mw, first.hnd,
),
}
for _, env := range []*testEnv{first, second, third} {
env.get("/", nil)
scrape := env.metricsRequest(metricsUser, metricsAuthValue)
require.Equal(t, http.StatusOK, scrape.Code)
for _, series := range []string{
"http_request_duration_seconds",
"http_response_size_bytes",
"http_requests_inflight",
"webhooker_events_received_total",
"go_goroutines",
"process_start_time_seconds",
"promhttp_metric_handler_requests_total",
} {
assert.Contains(t, scrape.Body.String(), series)
}
}
}
// TestMetricsScrapeBeforeAnyDelivery pins
// https://git.eeqj.de/sneak/webhooker/issues/267: an instance that
// has delivered nothing must still serve the delivery duration
// histogram, at zero, for every target type.
func TestMetricsScrapeBeforeAnyDelivery(t *testing.T) {
t.Parallel()
env := newTestEnvWithConfig(
t, metricsConfig(t, metricsUser, metricsAuthValue),
)
scrape := env.metricsRequest(metricsUser, metricsAuthValue)
require.Equal(t, http.StatusOK, scrape.Code)
for _, targetType := range []database.TargetType{
database.TargetTypeHTTP,
database.TargetTypeDatabase,
database.TargetTypeLog,
database.TargetTypeSlack,
} {
assert.Contains(
t, scrape.Body.String(),
`webhooker_delivery_duration_seconds_count{target_type="`+
string(targetType)+`"} 0`,
)
}
}
-6
View File
@@ -159,12 +159,6 @@ func (s *Server) Run() {
s.serve() s.serve()
} }
// MaintenanceMode returns whether the server is in maintenance
// mode.
func (s *Server) MaintenanceMode() bool {
return s.params.Config.MaintenanceMode
}
// enableSentry initialises the Sentry SDK when error reporting is // enableSentry initialises the Sentry SDK when error reporting is
// configured, and reports the failure when it is configured and cannot // configured, and reports the failure when it is configured and cannot
// be initialised. A DSN that is not set is not a failure: reporting // be initialised. A DSN that is not set is not a failure: reporting
+24
View File
@@ -0,0 +1,24 @@
package server_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSettingsPageIsBehindLogin(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
w := env.get("/settings", nil)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, "/pages/login?next=%2Fsettings", w.Header().Get("Location"),
)
w = env.get("/settings", env.authCookies(t, "id", "admin"))
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "WEBHOOKER_ENVIRONMENT")
}
-10
View File
@@ -1,10 +0,0 @@
package session
import "github.com/gorilla/sessions"
// NewStore exposes the production cookie-store constructor so tests
// exercise the store the application actually runs with, rather than a
// lookalike assembled in the test.
func NewStore(key []byte) *sessions.CookieStore {
return newStore(key)
}
+7
View File
@@ -8,6 +8,13 @@ import (
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
) )
// NewStore exposes the production cookie-store constructor so tests
// exercise the store the application actually runs with, rather than a
// lookalike assembled in the test.
func NewStore(key []byte) *sessions.CookieStore {
return newStore(key)
}
// NewForTest creates a Session with a pre-configured cookie store for use // NewForTest creates a Session with a pre-configured cookie store for use
// in tests. This bypasses the fx lifecycle and database dependency, allowing // in tests. This bypasses the fx lifecycle and database dependency, allowing
// middleware and handler tests to use real session functionality. The key // middleware and handler tests to use real session functionality. The key
+5 -4
View File
@@ -1,15 +1,16 @@
#!/bin/sh #!/bin/sh
# script/assets: extract Alpine.js from its npm package tarball, committed # script/assets: extract Alpine.js from its npm package tarball, committed
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The # in 3p/, to static/js/alpine.min.js, where go:embed reads it. The package
# extracted file is not committed. script/test, make build and make dev run # is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy
# this first. # forbids eval. The extracted file is not committed. script/test, make
# build and make dev run this first.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \ tar -xzOf 3p/alpinejs-csp-3.14.9.tgz package/dist/cdn.min.js \
>static/js/alpine.min.js >static/js/alpine.min.js
} }
+47 -8
View File
@@ -2,9 +2,10 @@
# script/test: run the test suite. # script/test: run the test suite.
# #
# -timeout is applied by `go test` per package, not to the run as a whole, so # -timeout is applied by `go test` per package, not to the run as a whole, so
# it only has to clear the slowest single package. That is internal/handlers, # it only has to clear the slowest single package. When this budget was set
# measured in a cache-defeated builder stage on the 48-core shared build host # that was internal/handlers, measured in a cache-defeated builder stage on the
# (2026-08-18); load- and host-dependent, not invariants: # 48-core shared build host (2026-08-18); load- and host-dependent, not
# invariants:
# #
# 16.9s host load 5-20, GOMAXPROCS 48 # 16.9s host load 5-20, GOMAXPROCS 48
# 45.9s / 47.3s / 49.0s three runs at deliberate host load 31-73 # 45.9s / 47.3s / 49.0s three runs at deliberate host load 31-73
@@ -23,15 +24,26 @@
# a condition CI runs under. If a CPU-limited runner ever puts a real run near # a condition CI runs under. If a CPU-limited runner ever puts a real run near
# 67s, that is the datum to revisit the org figure with. # 67s, that is the datum to revisit the org figure with.
# #
# Those figures predate tests hashing the admin password at 1 MB instead of
# 64 MB (https://git.eeqj.de/sneak/webhooker/pulls/404). After that change, in
# a cache-defeated build at host load 44-109 (2026-10-02), internal/handlers
# took 8.5s and the slowest package was internal/database at 15.8s. Once its
# retention tests seeded 50 rows per insert instead of 500
# (https://git.eeqj.de/sneak/webhooker/issues/198), internal/database took
# 7.3s and the slowest package was internal/handlers at 8.1s to 10.0s, at host
# load 25-48 (2026-10-02).
#
# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test # -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test
# binaries build or run at once, each with at most eight parallel tests. Under # binaries build or run at once, each with at most eight parallel tests. Under
# -race every test binary and every link costs a few hundred MB, so the # -race every test binary and every link costs a few hundred MB, so the
# defaults (one per core) add up to several GB on a many-core host. # defaults (one per core) add up to several GB on a many-core host.
# #
# No -v: the Docker build cuts each step's log off at 2 MiB, and verbose output # The first run has no -v: go test then prints one result line per package,
# from the whole suite passes that before a failure is printed. Without it, go # with its coverage, and for a package that fails, everything its tests wrote,
# test prints one result line per package and, for a package that fails, # application log lines included. Verbose output from the whole suite passes
# everything its tests wrote, application log lines included. # the 2 MiB at which the Docker build cuts off each step's log, so on a failure
# only the tests that failed run again, with -v. The script exits 1 after that
# rerun whatever its result: the first run already showed the suite is broken.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -39,7 +51,34 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$ROOT/script/assets" "$ROOT/script/assets"
go test -race -p 4 -parallel 8 -timeout 90s ./...
log="$(mktemp -t webhooker-test.XXXXXXXX)"
rcfile="$(mktemp -t webhooker-test-rc.XXXXXXXX)"
trap 'rm -f "$log" "$rcfile"' EXIT INT TERM
# The pipeline's status is tee's, and POSIX sh has no pipefail, so go
# test's status travels via a file. Output still streams live.
{
go test -race -cover -p 4 -parallel 8 -timeout 90s ./... 2>&1 \
&& echo 0 >"$rcfile" || echo $? >"$rcfile"
} | tee "$log"
if [ "$(cat "$rcfile")" -eq 0 ]; then
return
fi
# go test reports a failed test as a line starting "--- FAIL: TestName"
# (a failed subtest's line is indented, and reruns with its parent), and
# a failed package as "FAIL<tab>package/path<tab>...". A failure that
# names no test, such as a build error or a timeout, is already shown in
# full above, so there is nothing to rerun.
tests="$(awk '/^--- FAIL: / { print $3 }' "$log" | paste -s -d '|' -)"
packages="$(awk '/^FAIL\t/ { print $2 }' "$log")"
if [ -n "$tests" ]; then
echo "--- Rerunning the failed tests with -v for details ---"
go test -race -v -p 4 -parallel 8 -timeout 90s \
-run "^($tests)\$" $packages || true
fi
exit 1
} }
main "$@" main "$@"
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# script/test-browser: run the browser test in internal/server. It runs in
# Docker: Dockerfile.browser builds the test and runs it in a digest-pinned
# headless browser image, so the host needs no browser.
#
# --no-cache-filter=browser runs the test again even when nothing changed;
# it must name the stage in Dockerfile.browser that runs it.
# --output=type=cacheonly leaves no image behind to clean up.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build \
-f Dockerfile.browser \
--no-cache-filter=browser \
--progress=plain \
--output=type=cacheonly \
.
}
main "$@"
+70
View File
@@ -57,3 +57,73 @@
init(); init();
} }
})(); })();
// Alpine.js components.
//
// The pages' Content-Security-Policy forbids eval, so the UI loads
// Alpine's CSP build, which cannot run expressions written in the
// markup: a directive in templates/ may only name a property or method,
// and each x-data names a component registered here. This script runs
// before Alpine, whose script tag is deferred, so this listener is in
// place when Alpine starts.
document.addEventListener("alpine:init", function () {
"use strict";
// Something a click shows and hides: the mobile menu, an add form,
// an event in the event log, a delivery's attempts.
window.Alpine.data("collapsible", function () {
return {
open: false,
toggle() {
this.open = !this.open;
},
get closed() {
return !this.open;
},
// Turns a downward caret up while open.
get caretClass() {
return { "rotate-180": this.open };
},
};
});
// The add target form. Only the chosen type's fields show, and the
// others are disabled so that the form does not submit them.
//
// The type is read from the type select when Alpine starts, when the
// select changes, and on pageshow. Going back to the page, the
// browser restores the type chosen before without a change event,
// in some browsers only after Alpine has started, but always before
// pageshow.
window.Alpine.data("targetForm", function () {
return {
targetType: "",
init() {
this.readType();
},
readType() {
this.targetType = this.$root.querySelector(
'select[name="type"]'
).value;
},
get isHttp() {
return this.targetType === "http";
},
get isSlack() {
return this.targetType === "slack";
},
get isDatabase() {
return this.targetType === "database";
},
get notHttp() {
return !this.isHttp;
},
get notSlack() {
return !this.isSlack;
},
get notDatabase() {
return !this.isDatabase;
},
};
});
});
+1
View File
@@ -7,6 +7,7 @@
<body class="bg-gray-50 min-h-screen flex flex-col"> <body class="bg-gray-50 min-h-screen flex flex-col">
<div class="flex-grow"> <div class="flex-grow">
{{template "navbar" .}} {{template "navbar" .}}
{{template "notice" .}}
{{block "content" .}}{{end}} {{block "content" .}}{{end}}
</div> </div>
{{template "footer" .}} {{template "footer" .}}
+5 -3
View File
@@ -1,5 +1,5 @@
{{define "navbar"}} {{define "navbar"}}
<nav class="app-bar" x-data="{ open: false }"> <nav class="app-bar" x-data="collapsible">
<div class="max-w-6xl mx-auto flex justify-between items-center"> <div class="max-w-6xl mx-auto flex justify-between items-center">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a> <a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a>
@@ -7,9 +7,9 @@
<!-- Mobile menu button --> <!-- Mobile menu button -->
{{if .User}} {{if .User}}
<button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100"> <button @click="toggle" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/> <path x-show="closed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> <path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg> </svg>
</button> </button>
@@ -19,6 +19,7 @@
<div class="hidden md:flex items-center gap-4"> <div class="hidden md:flex items-center gap-4">
{{if .User}} {{if .User}}
<a href="/hooks" class="btn-text">Webhooks</a> <a href="/hooks" class="btn-text">Webhooks</a>
<a href="/settings" class="btn-text">Settings</a>
<a href="/user/{{.User.Username}}" class="btn-text"> <a href="/user/{{.User.Username}}" class="btn-text">
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16"> <svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/> <path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
@@ -43,6 +44,7 @@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{{if .User}} {{if .User}}
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a> <a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
<a href="/settings" class="btn-text w-full text-left">Settings</a>
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a> <a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
{{if .CSRFToken}} {{if .CSRFToken}}
<form method="POST" action="/pages/logout"> <form method="POST" action="/pages/logout">
+7
View File
@@ -0,0 +1,7 @@
{{define "notice"}}
{{with .Notice}}
<div class="max-w-6xl mx-auto px-6 pt-4">
<div class="{{if .Failed}}alert-error{{else}}alert-success{{end}}">{{.Text}}</div>
</div>
{{end}}
{{end}}
+30
View File
@@ -0,0 +1,30 @@
{{template "base" .}}
{{define "title"}}Settings - Webhooker{{end}}
{{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8">
<h1 class="text-2xl font-medium text-gray-900">Settings</h1>
<p class="text-sm text-gray-500 mt-1 mb-6">The configuration this server started with. It is set in the server's environment and cannot be changed here.</p>
<div class="card">
<div class="divide-y divide-gray-100">
{{range .Settings}}
<div class="p-4">
<!-- A value too wide to sit beside its name moves to the
next line, where a list breaks only at the spaces
between its entries. overflow-wrap: anywhere breaks
inside a value only when it alone is wider than the
line; an inline style, because the committed
tailwind.css has no class for it. -->
<div class="flex flex-wrap justify-between items-start gap-4">
<code class="text-sm font-medium text-gray-900">{{.Name}}</code>
<code class="text-sm text-gray-900" style="overflow-wrap: anywhere">{{.Value}}</code>
</div>
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
</div>
{{end}}
</div>
</div>
</div>
{{end}}
+25 -21
View File
@@ -3,10 +3,14 @@
{{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" x-data="{ showAddEntrypoint: false, showAddTarget: false }"> <!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the
event log, the navbar and the footer, so an entrypoint URL fits on
one line. An inline style, because the committed tailwind.css has
no class this wide. -->
<div class="mx-auto px-6 py-8" style="max-width: 108rem">
<div class="mb-6"> <div class="mb-6">
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a> <a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a>
<div class="flex justify-between items-center mt-2"> <div class="flex flex-wrap justify-between items-center gap-2 mt-2">
<div> <div>
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1> <h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
{{if .Webhook.Description}} {{if .Webhook.Description}}
@@ -28,10 +32,10 @@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Entrypoints --> <!-- Entrypoints -->
<div class="card"> <div class="card" x-data="collapsible">
<div class="p-4 border-b border-gray-200 flex justify-between items-center"> <div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">Entrypoints</h2> <h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
<button @click="showAddEntrypoint = !showAddEntrypoint" class="btn-text text-sm"> <button @click="toggle" class="btn-text text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg> </svg>
@@ -40,7 +44,7 @@
</div> </div>
<!-- Add entrypoint form --> <!-- Add entrypoint form -->
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200"> <div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/hook/{{.Webhook.ID}}/entrypoints" class="flex gap-2"> <form method="POST" action="/hook/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1"> <input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
@@ -87,10 +91,10 @@
</div> </div>
<!-- Targets --> <!-- Targets -->
<div class="card"> <div class="card" x-data="collapsible">
<div class="p-4 border-b border-gray-200 flex justify-between items-center"> <div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">Targets</h2> <h2 class="text-lg font-medium text-gray-900">Targets</h2>
<button @click="showAddTarget = !showAddTarget" class="btn-text text-sm"> <button @click="toggle" class="btn-text text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg> </svg>
@@ -99,42 +103,42 @@
</div> </div>
<!-- Add target form --> <!-- Add target form -->
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200"> <div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3"> <form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="targetForm" @pageshow.window="readType" class="space-y-3">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="flex gap-2"> <div class="flex gap-2">
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1"> <input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
<select name="type" x-model="targetType" class="input text-sm w-32"> <select name="type" @change="readType" class="input text-sm w-32">
<option value="http">HTTP</option> <option value="http">HTTP</option>
<option value="slack">Slack</option> <option value="slack">Slack</option>
<option value="database">Database</option> <option value="database">Database</option>
<option value="log">Log</option> <option value="log">Log</option>
</select> </select>
</div> </div>
<div x-show="targetType === 'http'"> <div x-show="isHttp">
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm"> <input type="url" name="url" placeholder="https://example.com/webhook" :disabled="notHttp" class="input text-sm">
</div> </div>
<div x-show="targetType === 'http'"> <div x-show="isHttp">
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="targetType !== 'http'" class="input text-sm"></textarea> <textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="notHttp" class="input text-sm"></textarea>
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p> <p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
</div> </div>
<div x-show="targetType === 'http'" class="flex gap-2 items-center"> <div x-show="isHttp" class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label> <label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
<input type="number" name="timeout" min="0" max="300" :disabled="targetType !== 'http'" class="input text-sm w-24"> <input type="number" name="timeout" min="0" max="300" :disabled="notHttp" class="input text-sm w-24">
</div> </div>
<div x-show="targetType === 'http'"> <div x-show="isHttp">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Max retries:</label> <label class="text-sm text-gray-700">Max retries:</label>
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24"> <input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
</div> </div>
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p> <p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
</div> </div>
<div x-show="targetType === 'slack'"> <div x-show="isSlack">
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" class="input text-sm"> <input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="notSlack" class="input text-sm">
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p> <p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
</div> </div>
<div x-show="targetType === 'database'"> <div x-show="isDatabase">
<input type="text" name="expiry" placeholder="never" :disabled="targetType !== 'database'" class="input text-sm"> <input type="text" name="expiry" placeholder="never" :disabled="notDatabase" class="input text-sm">
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p> <p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
</div> </div>
<button type="submit" class="btn-primary text-sm">Add Target</button> <button type="submit" class="btn-primary text-sm">Add Target</button>

Some files were not shown because too many files have changed in this diff Show More