Compare commits
7
Commits
f983ec724c
...
3eb77b96a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eb77b96a2 | ||
|
|
c513816a55 | ||
|
|
2bb4683512 | ||
|
|
5b1d283d06 | ||
|
|
b78abdc9da | ||
|
|
c23ffbac65 | ||
|
|
2ac4d4d793 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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), ", ")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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">`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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" .}}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -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">← Back to webhooks</a>
|
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||||
<div class="flex justify-between items-center mt-2">
|
<div class="flex 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}}
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user