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

Model: opus-5-5
2026-10-02 09:32:16 +00:00
clawbot c513816a55 Refuse [::], 0.0.0.0, IPv6 multicast and documentation space (closes #341)
check / check (push) Successful in 3m20s
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) Successful in 3m19s
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) Successful in 3m20s
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) Successful in 3m11s
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) Successful in 3m16s
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) Successful in 3m25s
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
40 changed files with 1101 additions and 318 deletions
+62 -42
View File
@@ -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
@@ -2780,6 +2797,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 |
@@ -2893,6 +2911,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/
@@ -3002,14 +3021,14 @@ local record instead of nothing. What that placement gives up is
recovery of a panic in the six entries above it, none of which does 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
@@ -3028,7 +3047,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
@@ -3074,12 +3093,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
@@ -3093,7 +3112,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
-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
+7 -6
View File
@@ -196,12 +196,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
+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),
) )
+14
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"
@@ -146,6 +147,7 @@ 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 Metrics *metrics.Set
@@ -168,6 +170,10 @@ type Engine struct {
retryCh chan Task retryCh chan Task
workers int workers int
// version is the running build's version, the one the web UI
// footer shows. userAgent puts it on every outbound request.
version string
// mtr is the delivery metric set. Production wires the one // mtr is the delivery metric set. Production wires the one
// registered on the registry /metrics serves; a test can // registered on the registry /metrics serves; a test can
// substitute a set registered on a registry it holds, so it can // substitute a set registered on a registry it holds, so it can
@@ -205,6 +211,7 @@ 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,
version: params.Globals.Version,
mtr: params.Metrics, mtr: params.Metrics,
} }
@@ -301,6 +308,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
@@ -1247,11 +1247,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
@@ -1320,6 +1315,7 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
ContentType: tc.event, ContentType: tc.event,
}, },
cfg, cfg,
"webhooker/dev",
) )
assert.Equal(t, assert.Equal(t,
+2 -1
View File
@@ -83,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.
+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()
+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,
+4 -2
View File
@@ -333,7 +333,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,
)
} }
} }
+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"),
) )
+20 -8
View File
@@ -14,6 +14,7 @@ import (
"github.com/prometheus/client_golang/prometheus" "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"
@@ -57,6 +58,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
@@ -97,10 +99,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 {
@@ -109,6 +111,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(
@@ -139,6 +142,7 @@ func New(
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"),
@@ -209,11 +213,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
@@ -267,6 +273,7 @@ type templateDataWrapper struct {
User *UserInfo User *UserInfo
CSRFToken string CSRFToken string
Version string Version string
Notice *notice
Data any Data any
} }
@@ -311,12 +318,15 @@ 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)))
} }
// 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)
@@ -330,6 +340,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
} }
@@ -338,6 +349,7 @@ 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,
} }
} }
+18 -5
View File
@@ -84,16 +84,29 @@ func newTestApp(
) *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,
+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)
}
+135
View File
@@ -0,0 +1,135 @@
package handlers
import (
"net/http"
"net/netip"
"strconv"
"strings"
"sneak.berlin/go/webhooker/internal/config"
)
// notSet is what the Settings page shows for a value that is empty.
const notSet = "not set"
// settingRow is one line of the Settings page: an environment
// variable, what it controls, and the value the server loaded for it.
type settingRow struct {
Name string
Description string
Value string
}
// HandleSettings returns a handler for the read-only Settings page,
// which lists the configuration the server started with.
func (h *Handlers) HandleSettings() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.renderTemplate(w, r, "settings.html", map[string]any{
"Settings": settingRows(h.params.Config),
})
}
}
// settingRows lists every field of cfg under the environment variable
// it is read from, with the description the README's configuration
// table gives it (less its pointers to other README sections), in the
// table's order. METRICS_PASSWORD and SENTRY_DSN are credentials, so
// their values never reach the page: only whether they are set.
func settingRows(cfg *config.Config) []settingRow {
metricsUsername := cfg.MetricsUsername
if metricsUsername == "" {
metricsUsername = notSet
}
return []settingRow{
{"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment},
{"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)},
{
"BIND_ADDRESS",
"IP address the HTTP listener binds. Loopback by default, " +
"so the cleartext listener is not published on every " +
"interface. The Docker image ships 0.0.0.0 instead",
cfg.BindAddress,
},
{"DATA_DIR", "Directory for all SQLite databases", cfg.DataDir},
{"DEBUG", "Enable debug logging", strconv.FormatBool(cfg.Debug)},
{
"MAINTENANCE_MODE",
"Report maintenanceMode: true in the healthcheck JSON. " +
"It does not change how any request is served — no " +
"maintenance page exists",
strconv.FormatBool(cfg.MaintenanceMode),
},
{
"METRICS_USERNAME",
"Basic auth username for /metrics. Must be set together " +
"with METRICS_PASSWORD; one without the other fails " +
"startup",
metricsUsername,
},
{
"METRICS_PASSWORD",
"Basic auth password for /metrics. Must be set together " +
"with METRICS_USERNAME; one without the other fails " +
"startup",
setOrNotSet(cfg.MetricsPassword),
},
{
"SENTRY_DSN",
"Sentry error reporting DSN. Unset leaves error reporting " +
"off; a value the Sentry SDK cannot parse fails startup " +
"rather than serving with reporting silently off",
setOrNotSet(cfg.SentryDSN),
},
{
"RETENTION_SWEEP_INTERVAL",
"How often the retention reaper and archive sweeper run " +
"(Go duration, must be positive)",
cfg.RetentionSweepInterval.String(),
},
{
"SESSION_IDLE_TIMEOUT",
"Idle session timeout (Go duration)",
cfg.SessionIdleTimeout.String(),
},
{
"RECEIVER_RATE_LIMIT",
"Receiver requests/minute per IP per entrypoint " +
"(10x that per IP across the route)",
strconv.Itoa(cfg.ReceiverRateLimit),
},
{
"TRUSTED_PROXIES",
"CIDRs whose forwarded headers are trusted. A set value " +
"replaces the default. If any client can reach webhooker, " +
"or the proxy in front of it, from an RFC 1918 source " +
"address, set it to the proxy's address alone",
cidrList(cfg.TrustedProxies),
},
{
"ALLOWED_EGRESS_CIDRS",
"CIDRs that delivery targets may reach despite the " +
"SSRF blocklist",
cidrList(cfg.AllowedEgressCIDRs),
},
}
}
// setOrNotSet is how the Settings page shows a credential: whether it
// has a value, never the value itself.
func setOrNotSet(value string) string {
if value == "" {
return notSet
}
return "set"
}
// cidrList renders a CIDR list setting for the Settings page.
func cidrList(prefixes []netip.Prefix) string {
if len(prefixes) == 0 {
return "none"
}
return strings.Join(config.PrefixStrings(prefixes), ", ")
}
+151
View File
@@ -0,0 +1,151 @@
package handlers_test
import (
"context"
"html"
"net/http"
"net/http/httptest"
"net/netip"
"regexp"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
// settingsShown renders the Settings page over cfg as a logged-in user
// and returns the value it shows for each variable name, plus the
// whole page.
func settingsShown(
t *testing.T, cfg *config.Config,
) (map[string]string, string) {
t.Helper()
var h *handlers.Handlers
var sess *session.Session
app := newTestAppWithConfig(t, cfg, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/settings", nil,
)
for _, c := range authenticatedCookies(t, sess, "id", "admin") {
req.AddCookie(c)
}
w := httptest.NewRecorder()
h.HandleSettings().ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
body := w.Body.String()
row := regexp.MustCompile(
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
)
shown := map[string]string{}
for _, match := range row.FindAllStringSubmatch(body, -1) {
shown[match[1]] = html.UnescapeString(match[2])
}
return shown, body
}
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
t.Parallel()
// DEBUG and MAINTENANCE_MODE get opposite values, and each of
// METRICS_USERNAME, METRICS_PASSWORD and SENTRY_DSN is the only one
// of the three set in one of the content tests, so each row is
// checked against its own field.
cfg := &config.Config{
DataDir: t.TempDir(),
Debug: true,
MaintenanceMode: false,
Environment: config.EnvironmentDev,
MetricsUsername: "scraper",
MetricsPassword: "",
Port: 9123,
SentryDSN: "",
BindAddress: "192.0.2.10",
RetentionSweepInterval: 17 * time.Minute,
SessionIdleTimeout: 3 * time.Hour,
ReceiverRateLimit: 77,
TrustedProxies: []netip.Prefix{
netip.MustParsePrefix("10.1.0.0/16"),
},
AllowedEgressCIDRs: []netip.Prefix{
netip.MustParsePrefix("192.168.5.0/24"),
netip.MustParsePrefix("fd00::/8"),
},
}
shown, body := settingsShown(t, cfg)
assert.Equal(t, map[string]string{
"WEBHOOKER_ENVIRONMENT": "dev",
"PORT": "9123",
"BIND_ADDRESS": "192.0.2.10",
"DATA_DIR": cfg.DataDir,
"DEBUG": "true",
"MAINTENANCE_MODE": "false",
"METRICS_USERNAME": "scraper",
"METRICS_PASSWORD": "not set",
"SENTRY_DSN": "not set",
"RETENTION_SWEEP_INTERVAL": "17m0s",
"SESSION_IDLE_TIMEOUT": "3h0m0s",
"RECEIVER_RATE_LIMIT": "77",
"TRUSTED_PROXIES": "10.1.0.0/16",
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
}, shown)
assert.Contains(
t, body, `href="/settings"`,
"the navigation bar links to the page",
)
}
func TestSettingsPageShowsUnsetValues(t *testing.T) {
t.Parallel()
const metricsPassword = "metrics-password-1f9a"
shown, body := settingsShown(t, &config.Config{
DataDir: t.TempDir(),
MetricsPassword: metricsPassword,
})
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
assert.Equal(t, "set", shown["METRICS_PASSWORD"])
assert.Equal(t, "not set", shown["SENTRY_DSN"])
assert.NotContains(t, body, metricsPassword)
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
}
func TestSettingsPageShowsSentryDSNOnlyAsSet(t *testing.T) {
t.Parallel()
const (
sentryKey = "dsnkey7c2e"
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
)
shown, body := settingsShown(t, &config.Config{
DataDir: t.TempDir(),
SentryDSN: sentryDSN,
})
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
assert.Equal(t, "set", shown["SENTRY_DSN"])
assert.NotContains(t, body, sentryKey)
}
+3 -1
View File
@@ -411,7 +411,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">`,
)
}
+51 -45
View File
@@ -322,7 +322,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,
) )
} }
@@ -579,7 +580,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,
) )
} }
@@ -662,7 +664,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,
@@ -841,31 +845,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)
@@ -1254,7 +1243,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,
) )
} }
} }
@@ -1365,7 +1355,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,
) )
} }
@@ -1569,10 +1560,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 " +
@@ -1643,6 +1635,7 @@ func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
"entrypointID", &database.Entrypoint{}, "entrypointID", &database.Entrypoint{},
"failed to delete entrypoint", "failed to delete entrypoint",
nil, nil,
entrypointDeleted,
) )
} }
@@ -1655,18 +1648,21 @@ func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
"targetID", &database.Target{}, "targetID", &database.Target{},
"failed to delete target", "failed to delete target",
h.evictArchiveWriterIfUnused, h.evictArchiveWriterIfUnused,
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 webhook's id once the // optional afterDelete hook runs with the webhook's id once the
// delete has succeeded, before the redirect. // delete has succeeded, 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(webhookID string), afterDelete func(webhookID 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)
@@ -1708,7 +1704,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,
) )
} }
@@ -1719,7 +1715,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(
@@ -1727,14 +1723,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,
) )
} }
@@ -1742,7 +1739,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(
@@ -1750,23 +1747,27 @@ 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 tgt.Active = !tgt.Active
return h.db.DB().Save(&tgt).Error return tgt.Active, h.db.DB().Save(&tgt).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)
@@ -1792,16 +1793,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,
) )
} }
+2 -1
View File
@@ -161,7 +161,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,
) )
} }
+4
View File
@@ -152,6 +152,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,
+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()
+24 -4
View File
@@ -14,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
@@ -154,6 +155,7 @@ func (s *Server) setupRoutes() {
s.setupPageRoutes() s.setupPageRoutes()
s.setupUserRoutes() s.setupUserRoutes()
s.setupSettingsRoutes()
s.setupSourceRoutes() s.setupSourceRoutes()
s.setupWebhookRoutes() s.setupWebhookRoutes()
} }
@@ -201,6 +203,24 @@ func (s *Server) setupUserRoutes() {
}) })
} }
// setupSettingsRoutes serves the Settings page. It is GET only:
// configuration comes from the environment and nothing here changes
// it.
func (s *Server) setupSettingsRoutes() {
s.router.Route("/settings", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSettings())
})
}
func (s *Server) setupSourceRoutes() { func (s *Server) setupSourceRoutes() {
s.router.Route("/hooks", func(r chi.Router) { s.router.Route("/hooks", func(r chi.Router) {
s.recoverPanics( s.recoverPanics(
+90 -34
View File
@@ -104,6 +104,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,
@@ -263,6 +267,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,
@@ -741,6 +763,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
@@ -858,9 +905,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,
) )
} }
@@ -891,8 +938,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
@@ -906,16 +952,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()
@@ -933,16 +980,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
@@ -951,7 +1001,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
@@ -960,7 +1010,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
@@ -971,8 +1024,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()
@@ -987,27 +1040,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
@@ -1017,8 +1072,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
@@ -1027,8 +1082,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
@@ -1064,9 +1122,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)
@@ -1302,10 +1360,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),
+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")
}
+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" .}}
+2
View File
@@ -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}}
+6 -2
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" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
<div class="mb-6"> <div class="mb-6">
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">&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}}
-8
View File
@@ -12,14 +12,6 @@
</div> </div>
</div> </div>
{{if .ReplayMessage}}
<div class="{{if .ReplayQueued}}alert-success{{else}}alert-error{{end}}">{{.ReplayMessage}}</div>
{{end}}
{{if .ResubmitMessage}}
<div class="{{if .ResubmitQueued}}alert-success{{else}}alert-error{{end}}">{{.ResubmitMessage}}</div>
{{end}}
<div class="card"> <div class="card">
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Events}} {{range .Events}}