Compare commits
2 Commits
9b3baec214
...
c4022c0834
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4022c0834 | ||
| be576096aa |
@@ -1,3 +1,6 @@
|
|||||||
|
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
|
||||||
|
# that keeps the check stages from replaying a cached pass. See the lint
|
||||||
|
# stage of the Dockerfile.
|
||||||
.git/
|
.git/
|
||||||
bin/
|
bin/
|
||||||
*.md
|
*.md
|
||||||
|
|||||||
@@ -11,5 +11,53 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
|
||||||
|
with:
|
||||||
|
# The fingerprint step below needs history to find the last commit
|
||||||
|
# that touched the Docker build context.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Neutralize superseded run statuses
|
||||||
|
# Gitea cancels the in-flight run when another commit is pushed to the
|
||||||
|
# same branch and records the cancellation as `failure`, so a commit
|
||||||
|
# that was never tested reads red. The cancellation is unconditional
|
||||||
|
# server-side for push events and cannot be disabled from a workflow
|
||||||
|
# file, so the superseding run rewrites those statuses to `skipped`.
|
||||||
|
# Only the exact cancellation status is touched; a real failure is
|
||||||
|
# left alone.
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
|
||||||
|
ctx='check / check (push)'
|
||||||
|
for sha in $(git rev-list --max-count=20 "${GITHUB_SHA}^" || true); do
|
||||||
|
latest="$(curl -sf "${api}/commits/${sha}/status" | jq -r \
|
||||||
|
--arg c "$ctx" \
|
||||||
|
'[.statuses[] | select(.context == $c)][0] // empty
|
||||||
|
| "\(.status)|\(.description)"')" || continue
|
||||||
|
[ "$latest" = 'failure|Has been cancelled' ] || continue
|
||||||
|
curl -sf -X POST "${api}/statuses/${sha}" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -nc --arg c "$ctx" '{
|
||||||
|
context: $c,
|
||||||
|
state: "skipped",
|
||||||
|
description: "Superseded by a newer commit; never tested"
|
||||||
|
}')" >/dev/null
|
||||||
|
echo "neutralized superseded status on ${sha}"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Fingerprint the build context
|
||||||
|
# `.dockerignore` keeps docs out of the build context, so a docs-only
|
||||||
|
# commit legitimately replays the whole image from cache and stays
|
||||||
|
# cheap. Every other commit writes a new fingerprint into the context,
|
||||||
|
# which invalidates the `COPY . .` layer of both check stages: a
|
||||||
|
# commit that was never linted, formatted-checked, tested and built
|
||||||
|
# cannot report success from cache.
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')"
|
||||||
|
printf '%s\n' "${fp:-$GITHUB_SHA}" > .ci-fingerprint
|
||||||
|
|
||||||
- name: Build Docker image (runs make check)
|
- name: Build Docker image (runs make check)
|
||||||
run: script/cibuild
|
run: script/cibuild
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -41,4 +41,7 @@ data/
|
|||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
|
# CI cache barrier, written into the build context by the check workflow
|
||||||
|
.ci-fingerprint
|
||||||
@@ -12,7 +12,11 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code. In CI the context also carries .ci-fingerprint, whose
|
||||||
|
# value changes with every commit that touches the build context (see
|
||||||
|
# .gitea/workflows/check.yml). That invalidates this layer, so the checks
|
||||||
|
# below cannot report success by replaying a cached pass. Do not add it to
|
||||||
|
# .dockerignore.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run formatting check and linter
|
# Run formatting check and linter
|
||||||
@@ -36,7 +40,8 @@ WORKDIR /build
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code, including the .ci-fingerprint cache barrier described in
|
||||||
|
# the lint stage above.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run tests and build
|
# Run tests and build
|
||||||
|
|||||||
47
README.md
47
README.md
@@ -93,7 +93,7 @@ TTY detection, and security headers are always applied.
|
|||||||
| `METRICS_USERNAME` | Basic auth username for `/metrics` | `""` |
|
| `METRICS_USERNAME` | Basic auth username for `/metrics` | `""` |
|
||||||
| `METRICS_PASSWORD` | Basic auth password for `/metrics` | `""` |
|
| `METRICS_PASSWORD` | Basic auth password for `/metrics` | `""` |
|
||||||
| `SENTRY_DSN` | Sentry error reporting DSN | `""` |
|
| `SENTRY_DSN` | Sentry error reporting DSN | `""` |
|
||||||
| `RETENTION_SWEEP_INTERVAL` | Retention reaper period (Go duration, must be positive) | `1h` |
|
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive) | `1h` |
|
||||||
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
|
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
|
||||||
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint | `120` |
|
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint | `120` |
|
||||||
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted | `""` (none) |
|
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted | `""` (none) |
|
||||||
@@ -265,9 +265,10 @@ webhooker solves this by acting as a durable intermediary:
|
|||||||
targets simultaneously. This enables patterns like forwarding a
|
targets simultaneously. This enables patterns like forwarding a
|
||||||
GitHub webhook to both a deployment service and a Slack channel.
|
GitHub webhook to both a deployment service and a Slack channel.
|
||||||
|
|
||||||
5. **Replay** — Stored events can be manually redelivered for debugging
|
5. **Replay** (not yet implemented) — Every received event is stored in
|
||||||
or testing, without requiring the original sender to fire the webhook
|
full, which is what manual redelivery for debugging or testing will
|
||||||
again.
|
be built on. No redelivery exists today, in the web UI or the API;
|
||||||
|
see [TODO.md](TODO.md).
|
||||||
|
|
||||||
### Use Cases
|
### Use Cases
|
||||||
|
|
||||||
@@ -277,6 +278,7 @@ webhooker solves this by acting as a durable intermediary:
|
|||||||
size, and delivery performance
|
size, and delivery performance
|
||||||
- **Debugging** and introspection of webhook payloads in the web UI
|
- **Debugging** and introspection of webhook payloads in the web UI
|
||||||
- **Replay** of webhook events for application testing and development
|
- **Replay** of webhook events for application testing and development
|
||||||
|
(planned; not yet implemented)
|
||||||
- **Fan-out** delivery of a single webhook to multiple downstream
|
- **Fan-out** delivery of a single webhook to multiple downstream
|
||||||
targets
|
targets
|
||||||
- **High-availability ingestion** for delivery to less reliable backend
|
- **High-availability ingestion** for delivery to less reliable backend
|
||||||
@@ -502,7 +504,7 @@ A programmatic access credential for API authentication.
|
|||||||
#### Event
|
#### Event
|
||||||
|
|
||||||
A captured incoming webhook request. Stores the complete HTTP request
|
A captured incoming webhook request. Stores the complete HTTP request
|
||||||
data for replay and auditing.
|
data for auditing and for the planned replay capability.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| -------------- | ------ | ----------- |
|
| -------------- | ------ | ----------- |
|
||||||
@@ -784,9 +786,10 @@ unknown) one while one of its deliveries is still `retrying`, both
|
|||||||
recovery paths above terminally mark that delivery `failed` and record a
|
recovery paths above terminally mark that delivery `failed` and record a
|
||||||
`DeliveryResult` naming the current target type as the reason, logging it
|
`DeliveryResult` naming the current target type as the reason, logging it
|
||||||
at warn level. The delivery is not re-dispatched under the new type — the
|
at warn level. The delivery is not re-dispatched under the new type — the
|
||||||
operator never asked for that delivery — and the event itself remains
|
operator never asked for that delivery — and while the event itself
|
||||||
stored in the per-webhook event database, so it can be redelivered
|
remains stored in the per-webhook event database, there is no way to
|
||||||
manually.
|
redeliver it: manual redelivery is planned, not implemented (see
|
||||||
|
[TODO.md](TODO.md)).
|
||||||
|
|
||||||
### Circuit Breaker (HTTP Targets with Retries)
|
### Circuit Breaker (HTTP Targets with Retries)
|
||||||
|
|
||||||
@@ -1109,6 +1112,34 @@ binary is statically linked and runs on Alpine.
|
|||||||
`docker build .` is the CI gate — if it passes, the code is formatted,
|
`docker build .` is the CI gate — if it passes, the code is formatted,
|
||||||
linted, tested, and compiled.
|
linted, tested, and compiled.
|
||||||
|
|
||||||
|
#### CI gate honesty
|
||||||
|
|
||||||
|
A layer cache lets `docker build .` exit 0 in seconds with the lint and
|
||||||
|
test stages replayed rather than executed, which would make a green
|
||||||
|
check meaningless. The `check` workflow therefore writes
|
||||||
|
`.ci-fingerprint` into the build context before building. Its value is
|
||||||
|
the hash of the last commit that touched the build context, so:
|
||||||
|
|
||||||
|
- Any commit that changes code (including a squash merge whose tree
|
||||||
|
matches an already-built branch) gets a new fingerprint, invalidates
|
||||||
|
the `COPY . .` layer of both check stages, and really runs
|
||||||
|
`make fmt-check`, `make lint`, `make test`, and `make build`. A run
|
||||||
|
that reports success ran them.
|
||||||
|
- A docs-only commit leaves the fingerprint unchanged — `.dockerignore`
|
||||||
|
excludes `*.md` and `LICENSE` from the context anyway — so the image
|
||||||
|
replays from cache and costs seconds.
|
||||||
|
|
||||||
|
The module download layer sits above `COPY . .` and stays cached either
|
||||||
|
way.
|
||||||
|
|
||||||
|
The workflow's first step covers a second way the gate lied: Gitea
|
||||||
|
cancels an in-flight run when a newer commit lands on the same branch
|
||||||
|
and records that cancellation as a `failure` status, marking a commit
|
||||||
|
red that was never tested. Cancellation is unconditional server-side for
|
||||||
|
push events, so the superseding run rewrites the exact
|
||||||
|
`Has been cancelled` status to `skipped`. Genuine failures are never
|
||||||
|
touched.
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
See [TODO.md](TODO.md).
|
See [TODO.md](TODO.md).
|
||||||
|
|||||||
31
TODO.md
31
TODO.md
@@ -61,6 +61,34 @@ rests on.
|
|||||||
Profile settings placeholder removed, a progressive-enhancement copy
|
Profile settings placeholder removed, a progressive-enhancement copy
|
||||||
button for the entrypoint URL, and retention form copy that states the
|
button for the entrypoint URL, and retention form copy that states the
|
||||||
actual policy (deletion by the reaper, 0 retains forever) (#57)
|
actual policy (deletion by the reaper, 0 retains forever) (#57)
|
||||||
|
- 2026-08-11 Mask the webhook credential in delivery errors and logs:
|
||||||
|
Go embeds the request URL in `*url.Error`, so every transport failure
|
||||||
|
persisted the full Slack webhook URL into the per-webhook event
|
||||||
|
database via `DeliveryResult.Error`, a field a future REST API would
|
||||||
|
have served. `maskURLError` drops path, query and userinfo while
|
||||||
|
preserving the wrapped cause, so `errors.Is`/`As` and `Timeout()`
|
||||||
|
still work and DNS, TLS and timeout failures still read differently
|
||||||
|
(#118)
|
||||||
|
- 2026-08-11 Rate-limit the public webhook receiver endpoint
|
||||||
|
(`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus
|
||||||
|
entrypoint path so one entrypoint cannot exhaust another's budget;
|
||||||
|
over-limit requests get 429 with `Retry-After`. It was the one
|
||||||
|
unauthenticated, internet-facing endpoint with no limit at all (#64)
|
||||||
|
- 2026-08-11 Enforce the body size limit before CSRF parses the form:
|
||||||
|
`MaxBodySize` is now first in all four form-parsing route groups, so
|
||||||
|
an oversized request is rejected with 413 instead of being read in
|
||||||
|
full by the CSRF middleware before any cap applied (#90)
|
||||||
|
- 2026-08-11 Mask target config on the source detail page, which
|
||||||
|
rendered the stored blob verbatim and so exposed the Slack
|
||||||
|
incoming-webhook URL — a bearer credential that cannot be revoked
|
||||||
|
per-holder. Config reaches the template only as a `TargetView` of
|
||||||
|
labelled fields, and header values are rendered as a count (#113)
|
||||||
|
- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a
|
||||||
|
sentinel written in `BeforeSave` so the GORM column default cannot
|
||||||
|
win the race. Also bounds the reaper's cutoff arithmetic: day counts
|
||||||
|
above 106751 overflowed `time.Duration` and wrapped the cutoff into
|
||||||
|
the future, where every row matched and the sweep deleted everything
|
||||||
|
(#79)
|
||||||
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry
|
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry
|
||||||
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated
|
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated
|
||||||
requests, with the 7-day absolute cap kept as an independent
|
requests, with the 7-day absolute cap kept as an independent
|
||||||
@@ -115,6 +143,9 @@ rests on.
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
|
- Manual event redelivery from the web UI — the "Replay" capability the
|
||||||
|
README describes as planned. No redelivery code exists anywhere in the
|
||||||
|
tree; events are stored in full, which is all it would be built on
|
||||||
- Delivery status and retry management UI
|
- Delivery status and retry management UI
|
||||||
- Per-webhook rate limiting in the receiver handler (per-webhook config
|
- Per-webhook rate limiting in the receiver handler (per-webhook config
|
||||||
plus handler enforcement; global limits must not apply to receiver
|
plus handler enforcement; global limits must not apply to receiver
|
||||||
|
|||||||
@@ -785,9 +785,9 @@ func (e *Engine) sweepSingleRetry(
|
|||||||
// status retrying themselves. Re-dispatching under the new type
|
// status retrying themselves. Re-dispatching under the new type
|
||||||
// would be a delivery the operator never asked for, and leaving
|
// would be a delivery the operator never asked for, and leaving
|
||||||
// the row retrying strands it forever, so the delivery is
|
// the row retrying strands it forever, so the delivery is
|
||||||
// failed with a recorded reason and can be redelivered
|
// failed with a recorded reason. The event stays stored, but
|
||||||
// manually. Logged at warn, not error: this is operator-caused
|
// nothing redelivers it today. Logged at warn, not error: this
|
||||||
// state, not a system fault.
|
// is operator-caused state, not a system fault.
|
||||||
func (e *Engine) failUnretryableRetry(
|
func (e *Engine) failUnretryableRetry(
|
||||||
webhookDB *gorm.DB,
|
webhookDB *gorm.DB,
|
||||||
webhookID string,
|
webhookID string,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// Webhooker client-side JavaScript
|
// Webhooker client-side JavaScript
|
||||||
console.log("Webhooker loaded");
|
|
||||||
|
|
||||||
// Copy-to-clipboard, as progressive enhancement.
|
// Copy-to-clipboard, as progressive enhancement.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user