Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aca0c981d | ||
|
|
38157d8936 |
+10
-4
@@ -1,14 +1,20 @@
|
|||||||
|
# .git is sent so the build can derive the version it stamps into the binary
|
||||||
|
# (script/version). Its config, which can hold a remote URL carrying a
|
||||||
|
# credential and which `git describe` does not need, is left out of a
|
||||||
|
# directory context. A context sent as a tar is not filtered by this file, so
|
||||||
|
# it carries .git/config unless its sender leaves it out.
|
||||||
|
.git/config
|
||||||
|
|
||||||
|
# No tracked file may be listed here: git in the build would see it as
|
||||||
|
# deleted and mark the version -dirty.
|
||||||
|
#
|
||||||
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
|
# .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
|
# that keeps the check stages from replaying a cached pass. See the lint
|
||||||
# stage of the Dockerfile.
|
# stage of the Dockerfile.
|
||||||
.git/
|
|
||||||
bin/
|
bin/
|
||||||
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
||||||
# needed. The tarball in 3p/ must stay in the context.
|
# needed. The tarball in 3p/ must stay in the context.
|
||||||
static/js/alpine.min.js
|
static/js/alpine.min.js
|
||||||
*.md
|
|
||||||
LICENSE
|
|
||||||
.editorconfig
|
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ jobs:
|
|||||||
- 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:
|
with:
|
||||||
# The fingerprint step below needs history to find the last commit
|
# The superseded-status step needs history to walk ancestors (it
|
||||||
# that touched the Docker build context, and the superseded-status
|
# aborts on a shallow clone).
|
||||||
# step needs it to walk ancestors (it aborts on a shallow clone).
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Mark superseded run statuses
|
- name: Mark superseded run statuses
|
||||||
@@ -28,16 +27,11 @@ jobs:
|
|||||||
run: script/ci-mark-superseded
|
run: script/ci-mark-superseded
|
||||||
|
|
||||||
- name: Fingerprint the build context
|
- name: Fingerprint the build context
|
||||||
# `.dockerignore` keeps docs out of the build context, so a docs-only
|
# Writes the hash of the commit being checked into the context, which
|
||||||
# commit legitimately replays the whole image from cache and stays
|
# invalidates the `COPY . .` layer of both check stages: a commit
|
||||||
# cheap. Every other commit writes a new fingerprint into the context,
|
# that was never linted, format-checked, tested and built cannot
|
||||||
# which invalidates the `COPY . .` layer of both check stages: a
|
# report success from cache.
|
||||||
# commit that was never linted, formatted-checked, tested and built
|
run: git rev-parse HEAD > .ci-fingerprint
|
||||||
# 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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+22
-9
@@ -12,8 +12,8 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source code. In CI the context also carries .ci-fingerprint, whose
|
# Copy source code. In CI the context also carries .ci-fingerprint, which
|
||||||
# value changes with every commit that touches the build context (see
|
# holds the hash of the commit being checked (see
|
||||||
# .gitea/workflows/check.yml). That invalidates this layer, so the checks
|
# .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
|
# below cannot report success by replaying a cached pass. Do not add it to
|
||||||
# .dockerignore.
|
# .dockerignore.
|
||||||
@@ -38,8 +38,13 @@ FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a349228
|
|||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
||||||
# suite executes.
|
# suite executes. git is what script/version derives the version with.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# A build context sent as a tar archive keeps its files' owners, and git
|
||||||
|
# refuses to read a checkout owned by another user. Trust this one
|
||||||
|
# whoever owns it.
|
||||||
|
RUN git config --system --add safe.directory /build
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@@ -55,14 +60,22 @@ COPY . .
|
|||||||
# from its tarball in 3p/.
|
# from its tarball in 3p/.
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
# Version stamped into the binary. .dockerignore excludes .git/, so
|
# Version stamped into the binary: the VERSION build arg when one is
|
||||||
# nothing in this stage can derive it: script/docker resolves it on the
|
# given, otherwise what script/version derives from the .git the build
|
||||||
# host and passes it in. The default is what a bare `docker build .`
|
# context carries, so any `docker build .` of a clone stamps its commit.
|
||||||
# with no --build-arg gets, and it names no tag the tree may not be at.
|
# With neither, as from a source tarball, it is "unknown".
|
||||||
#
|
#
|
||||||
# Declared here, below the test step, so a changed version does not
|
# Declared here, below the test step, so a changed version does not
|
||||||
# invalidate its cached layer.
|
# invalidate its cached layer.
|
||||||
ARG VERSION=unknown
|
ARG VERSION
|
||||||
|
|
||||||
|
# A context that carries .git must not stamp "unknown": that means git is
|
||||||
|
# missing here or could not read the checkout, and the image could not be
|
||||||
|
# traced back to its commit.
|
||||||
|
RUN if [ -d .git ] && [ "$(make version VERSION="$VERSION")" = unknown ]; then \
|
||||||
|
echo "version is unknown although the build context carries .git" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
RUN make build VERSION="$VERSION"
|
RUN make build VERSION="$VERSION"
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# Browser test image, built by script/test-browser (make test-browser). It
|
|
||||||
# runs the test in internal/server that loads the pages in a headless
|
|
||||||
# browser under the real Content-Security-Policy. That test is built only
|
|
||||||
# with the browser build tag, so make test leaves it out. Here the browser
|
|
||||||
# comes from a digest-pinned image, and if it is missing the test fails.
|
|
||||||
|
|
||||||
# golang:1.26.1-bookworm, 2026-03-17: the builder stage's image in Dockerfile.
|
|
||||||
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS build
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# The test binary embeds the templates and static files, so the browser
|
|
||||||
# stage needs nothing else. -p 4 keeps the compile's memory down, as in
|
|
||||||
# script/test.
|
|
||||||
RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server
|
|
||||||
|
|
||||||
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
|
|
||||||
# browser is on PATH as headless-shell, where the test's browser library
|
|
||||||
# looks for it.
|
|
||||||
FROM chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04 AS browser
|
|
||||||
|
|
||||||
COPY --from=build /browser.test /browser.test
|
|
||||||
|
|
||||||
RUN /browser.test -test.v -test.timeout 90s -test.run '^TestAlpineRunsUnderTheSecurityPolicy$'
|
|
||||||
+1
-3
@@ -34,6 +34,4 @@ COPY . .
|
|||||||
# `run` silently ignores config keys it does not recognize, so a typo would
|
# `run` silently ignores config keys it does not recognize, so a typo would
|
||||||
# disable a setting without a word. `config verify` is what catches that.
|
# disable a setting without a word. `config verify` is what catches that.
|
||||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||||
# --build-tags browser also lints the browser test, which is built only with
|
RUN --network=none golangci-lint run --config .golangci.yml ./...
|
||||||
# that tag (make test-browser).
|
|
||||||
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css version
|
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
|
|
||||||
# Version stamped into the binary. Derived from git by script/version;
|
# Version stamped into the binary. Derived from git by script/version;
|
||||||
# override it (`make build VERSION=v1.2.3`) where git metadata is
|
# override it (`make build VERSION=v1.2.3`) to stamp a given value, which is
|
||||||
# unavailable, which is how the Dockerfile passes its build arg in.
|
# how the Dockerfile passes its build arg in.
|
||||||
VERSION ?= $(shell script/version)
|
VERSION ?= $(shell script/version)
|
||||||
|
|
||||||
# An empty override (`make build VERSION=`, or a `--build-arg VERSION=`
|
# An empty override (`make build VERSION=`, or the Dockerfile's `make build
|
||||||
# landing on the Dockerfile's `make build VERSION="$VERSION"`) means unset,
|
# VERSION="$VERSION"` when no VERSION build arg was given) means unset,
|
||||||
# exactly as it does in script/version -- stamping "" would leave the binary
|
# exactly as it does in script/version -- stamping "" would leave the binary
|
||||||
# reporting no version and the footer back on its "dev" fallback. `override`
|
# reporting no version and the footer back on its "dev" fallback. `override`
|
||||||
# is required: a plain assignment loses to the command-line definition it
|
# is required: a plain assignment loses to the command-line definition it
|
||||||
@@ -33,9 +33,6 @@ assets:
|
|||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
test-browser:
|
|
||||||
@script/test-browser
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@script/lint
|
@script/lint
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ before deploying one.
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Go 1.26.1+ (the version in `go.mod`)
|
- Go 1.26.1+ (the version in `go.mod`)
|
||||||
- Docker (for linting, for the browser test, for the test stage of the
|
- Docker (for linting, for the test stage of the CI gate, and for
|
||||||
CI gate, and for containerized deployment)
|
containerized deployment)
|
||||||
|
|
||||||
golangci-lint is not a prerequisite and must not be installed on the
|
golangci-lint is not a prerequisite and must not be installed on the
|
||||||
host: `script/bootstrap` does not install it, and `make lint` runs the
|
host: `script/bootstrap` does not install it, and `make lint` runs the
|
||||||
@@ -58,7 +58,6 @@ make fmt # Format code (gofmt + goimports)
|
|||||||
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
||||||
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
||||||
make test # Run tests with race detection
|
make test # Run tests with race detection
|
||||||
make test-browser # Run the browser test in Docker (Dockerfile.browser)
|
|
||||||
make check # test + lint + fmt-check (CI gate)
|
make check # test + lint + fmt-check (CI gate)
|
||||||
make build # Build binary to bin/webhooker (version-stamped)
|
make build # Build binary to bin/webhooker (version-stamped)
|
||||||
make version # Print the version this checkout would stamp
|
make version # Print the version this checkout would stamp
|
||||||
@@ -1134,13 +1133,29 @@ build itself.
|
|||||||
| Uncommitted changes | the above with a `-dirty` suffix |
|
| Uncommitted changes | the above with a `-dirty` suffix |
|
||||||
| No git metadata | `unknown` |
|
| No git metadata | `unknown` |
|
||||||
|
|
||||||
`unknown` is what a source tarball or a `docker build .` with no
|
The image derives it the same way, from the `.git` that the build
|
||||||
`--build-arg VERSION=...` reports. `.dockerignore` excludes `.git/`, so
|
context carries, so any `docker build .` of a clone, with no build
|
||||||
the build context carries no git metadata and the image cannot derive
|
arguments, stamps the commit it was built from; a shallow clone of one
|
||||||
the version itself: `script/docker` (and so `make docker`) resolves it
|
branch has no tags and stamps the short SHA. `.dockerignore` must
|
||||||
on the host and passes it in as the `VERSION` build arg. A build that
|
therefore leave out neither `.git` nor any tracked file, which git in
|
||||||
reports `unknown` is a build nobody told what it was; it is not a
|
the build would see as deleted, marking the version `-dirty`. It does
|
||||||
failure, but it cannot be traced back to a commit.
|
leave `.git/config`, which can hold a remote URL carrying a credential
|
||||||
|
and which `git describe` does not need, out of a directory context. A
|
||||||
|
context sent as a tar is not filtered by `.dockerignore`, so it carries
|
||||||
|
`.git/config` unless its sender leaves it out; for upaas, that is
|
||||||
|
https://git.eeqj.de/sneak/upaas/issues/274. git in the build
|
||||||
|
reads the checkout whoever owns its files, since a context sent as a tar
|
||||||
|
archive keeps the sender's owners and git otherwise refuses a checkout
|
||||||
|
owned by another user. A `VERSION` build arg (`--build-arg VERSION=...`)
|
||||||
|
takes precedence; `script/docker` (and so `make docker`) passes the one
|
||||||
|
`script/version` resolves on the host. The image build fails if its
|
||||||
|
context carries `.git` and the version still comes out `unknown`, which
|
||||||
|
means git is missing from the build or could not read the checkout.
|
||||||
|
|
||||||
|
`unknown` is what a source tarball, or a `docker build` with no `.git`
|
||||||
|
in its context and no `VERSION` build arg, reports. A build that reports
|
||||||
|
`unknown` is a build nobody told what it was; it is not a failure, but
|
||||||
|
it cannot be traced back to a commit.
|
||||||
|
|
||||||
`make version` prints what the current checkout would stamp, and
|
`make version` prints what the current checkout would stamp, and
|
||||||
`make build VERSION=v1.2.3` overrides it. An empty override — from
|
`make build VERSION=v1.2.3` overrides it. An empty override — from
|
||||||
@@ -1225,7 +1240,7 @@ What that means for an operator:
|
|||||||
This repository adheres to the
|
This repository adheres to the
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
standard: normalized scripts in `script/` are the entrypoints for the
|
standard: normalized scripts in `script/` are the entrypoints for the
|
||||||
development workflow. Eleven of the Makefile's eighteen targets are thin
|
development workflow. Ten of the Makefile's seventeen targets are thin
|
||||||
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
|
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
|
||||||
`version` are inline commands with no script behind them, though `build`,
|
`version` are inline commands with no script behind them, though `build`,
|
||||||
`run` and `dev` first run `script/assets`, and `build` and `version` both
|
`run` and `dev` first run `script/assets`, and `build` and `version` both
|
||||||
@@ -1246,8 +1261,6 @@ We provide:
|
|||||||
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see
|
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see
|
||||||
[Third-party browser assets](#third-party-browser-assets))
|
[Third-party browser assets](#third-party-browser-assets))
|
||||||
- `script/test` — run the test suite
|
- `script/test` — run the test suite
|
||||||
- `script/test-browser` — run the browser test in Docker (see
|
|
||||||
[Third-party browser assets](#third-party-browser-assets))
|
|
||||||
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
||||||
- `script/fmt` — format all code (writes)
|
- `script/fmt` — format all code (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
@@ -1268,30 +1281,9 @@ We provide:
|
|||||||
|
|
||||||
## Third-party browser assets
|
## Third-party browser assets
|
||||||
|
|
||||||
The web UI serves one third-party script, Alpine.js, in its CSP build: the npm
|
The web UI serves one third-party script, Alpine.js. Its npm package tarball
|
||||||
package `@alpinejs/csp`. The pages' Content-Security-Policy forbids eval, which
|
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry
|
||||||
the standard `alpinejs` build needs to run the expressions written in the
|
publishes it. It is a dependency, not this repo's build output, so
|
||||||
markup. The CSP build runs no expressions, so every Alpine directive in
|
|
||||||
`templates/` only names a property or method of a component registered in
|
|
||||||
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
|
|
||||||
`x-data="{ open: false }"` or `@click="open = !open"`.
|
|
||||||
|
|
||||||
A browser test in `internal/server` loads the webhook page and the event log
|
|
||||||
under the real policy and checks that: both add forms stay hidden until Add is
|
|
||||||
clicked; choosing Slack in the add target form leaves the HTTP fields out of
|
|
||||||
what it submits, also after leaving the page and going back to it, when the
|
|
||||||
browser restores the choice; an event expands and collapses, and so do a
|
|
||||||
delivery's attempts inside it; and at phone width the menu button opens and
|
|
||||||
closes the mobile menu. It also fails if the browser reports a console warning or error,
|
|
||||||
an uncaught exception, or anything the policy refused. It is not part of
|
|
||||||
`make test`, `make check` or the image build (its file is built only with the
|
|
||||||
`browser` build tag). Run it with `make test-browser` after changing
|
|
||||||
`templates/` or `static/js/`: that builds `Dockerfile.browser`, which runs the
|
|
||||||
test in a digest-pinned headless browser image, so the host needs no browser.
|
|
||||||
|
|
||||||
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
|
|
||||||
byte as the npm registry publishes it. It is a dependency, not this repo's
|
|
||||||
build output, so
|
|
||||||
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
|
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
|
||||||
The directory is `3p/` rather than `vendor/` because Go treats a root
|
The directory is `3p/` rather than `vendor/` because Go treats a root
|
||||||
`vendor/` directory as its module vendor directory.
|
`vendor/` directory as its module vendor directory.
|
||||||
@@ -1304,11 +1296,10 @@ nothing downloads Alpine.js. The extracted file is not committed, and
|
|||||||
`.dockerignore` keeps any host copy out of the build context.
|
`.dockerignore` keeps any host copy out of the build context.
|
||||||
|
|
||||||
To move to a new version: download
|
To move to a new version: download
|
||||||
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it
|
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it
|
||||||
against the `dist.integrity` hash listed at
|
against the `dist.integrity` hash listed at
|
||||||
`https://registry.npmjs.org/@alpinejs/csp/<version>`, replace the tarball in
|
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
|
||||||
`3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
|
with it, update its file name in `script/assets`, and run `make check`.
|
||||||
`script/assets`, and run `make check`.
|
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
@@ -2840,7 +2831,7 @@ imports. The entry point is `cmd/webhooker/main.go`.
|
|||||||
```
|
```
|
||||||
webhooker/
|
webhooker/
|
||||||
├── 3p/
|
├── 3p/
|
||||||
│ └── alpinejs-csp-3.14.9.tgz # Alpine.js CSP build npm package, extracted by make assets
|
│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets
|
||||||
├── cmd/webhooker/
|
├── cmd/webhooker/
|
||||||
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
|
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
|
||||||
├── internal/
|
├── internal/
|
||||||
@@ -2934,14 +2925,13 @@ webhooker/
|
|||||||
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
|
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
|
||||||
│ ├── css/tailwind.css # Generated stylesheet the pages load
|
│ ├── css/tailwind.css # Generated stylesheet the pages load
|
||||||
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded
|
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded
|
||||||
│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
|
│ ├── js/app.js # Progressive-enhancement copy-to-clipboard
|
||||||
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
|
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed
|
||||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||||
├── script/ # Scripts to Rule Them All entrypoints
|
├── script/ # Scripts to Rule Them All entrypoints
|
||||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
||||||
├── Dockerfile.lint # Lint-only image built by script/lint
|
├── Dockerfile.lint # Lint-only image built by script/lint
|
||||||
├── Dockerfile.browser # Browser test image built by script/test-browser
|
├── Makefile # 10 of 17 targets shim script/; 7 are inline
|
||||||
├── Makefile # 11 of 18 targets shim script/; 7 are inline
|
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
└── .golangci.yml # Linter configuration
|
└── .golangci.yml # Linter configuration
|
||||||
```
|
```
|
||||||
@@ -3253,8 +3243,9 @@ version is fixed independently of the compiler's:
|
|||||||
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
||||||
runs on musl. Both builds go through `make build`, the relink adding
|
runs on musl. Both builds go through `make build`, the relink adding
|
||||||
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
||||||
stamps the version. The version arrives as the `VERSION` build arg,
|
stamps the version. The version is the `VERSION` build arg if one is
|
||||||
since the context has no `.git` (see
|
given, otherwise derived from the `.git` in the context, and the
|
||||||
|
stage fails if a context with `.git` would stamp `unknown` (see
|
||||||
[Version stamping](#version-stamping)).
|
[Version stamping](#version-stamping)).
|
||||||
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||||
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
||||||
@@ -3286,19 +3277,13 @@ 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
|
test stages replayed rather than executed, which would make a green
|
||||||
check meaningless. The `check` workflow therefore writes
|
check meaningless. The `check` workflow therefore writes
|
||||||
`.ci-fingerprint` into the build context before building. Its value is
|
`.ci-fingerprint` into the build context before building. Its value is
|
||||||
the hash of the last commit that touched the build context, so:
|
the hash of the commit being checked, so every commit, docs-only ones
|
||||||
|
and a squash merge whose tree matches an already-built branch included,
|
||||||
|
gets a new fingerprint, invalidates the `COPY . .` layer of both check
|
||||||
|
stages, and really runs `make fmt-check`, `golangci-lint`, `make test`,
|
||||||
|
and `make build`. A run that reports success ran them.
|
||||||
|
|
||||||
- Any commit that changes code (including a squash merge whose tree
|
The module download layer sits above `COPY . .` and stays cached.
|
||||||
matches an already-built branch) gets a new fingerprint, invalidates
|
|
||||||
the `COPY . .` layer of both check stages, and really runs
|
|
||||||
`make fmt-check`, `golangci-lint`, `make test`, and `make build`. A
|
|
||||||
run that reports success ran them.
|
|
||||||
- A docs-only commit leaves the fingerprint unchanged — `.dockerignore`
|
|
||||||
excludes `*.md`, `LICENSE` and `.editorconfig` 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.
|
|
||||||
|
|
||||||
A separate workflow step, run before the fingerprint is written, covers
|
A separate workflow step, run before the fingerprint is written, covers
|
||||||
a second way the gate lied: Gitea cancels an in-flight run when a newer
|
a second way the gate lied: Gitea cancels an in-flight run when a newer
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ go 1.26.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f
|
|
||||||
github.com/chromedp/chromedp v0.16.0
|
|
||||||
github.com/dustin/go-humanize v1.0.1
|
github.com/dustin/go-humanize v1.0.1
|
||||||
github.com/getsentry/sentry-go v0.25.0
|
github.com/getsentry/sentry-go v0.25.0
|
||||||
github.com/go-chi/chi v1.5.5
|
github.com/go-chi/chi v1.5.5
|
||||||
@@ -31,12 +29,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
|
|
||||||
github.com/gobwas/httphead v0.1.0 // indirect
|
|
||||||
github.com/gobwas/pool v0.2.1 // indirect
|
|
||||||
github.com/gobwas/ws v1.4.0 // indirect
|
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
@@ -57,7 +50,7 @@ require (
|
|||||||
go.uber.org/zap v1.23.0 // indirect
|
go.uber.org/zap v1.23.0 // indirect
|
||||||
golang.org/x/mod v0.17.0 // indirect
|
golang.org/x/mod v0.17.0 // indirect
|
||||||
golang.org/x/sync v0.14.0 // indirect
|
golang.org/x/sync v0.14.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
golang.org/x/text v0.25.0 // indirect
|
golang.org/x/text v0.25.0 // indirect
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
|
|||||||
@@ -6,12 +6,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
|||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk=
|
|
||||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE=
|
|
||||||
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
|
|
||||||
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
|
|
||||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
|
||||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -29,14 +23,6 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
|
|||||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
|
|
||||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
|
||||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
|
||||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
|
||||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
|
||||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
|
||||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
|
||||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
|
||||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
@@ -69,16 +55,12 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
|
||||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@@ -129,8 +111,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
|||||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||||
|
|||||||
@@ -40,11 +40,6 @@ const (
|
|||||||
ExportPendingSweepMinAge = pendingSweepMinAge
|
ExportPendingSweepMinAge = pendingSweepMinAge
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExportIsBlockedIP exposes isBlockedIP for testing.
|
|
||||||
func ExportIsBlockedIP(ip net.IP) bool {
|
|
||||||
return isBlockedIP(ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTestGuard builds an SSRF Guard from an explicit egress
|
// NewTestGuard builds an SSRF Guard from an explicit egress
|
||||||
// allowlist, without going through config. Passing no prefixes
|
// allowlist, without going through config. Passing no prefixes
|
||||||
// yields the default guard, which blocks every private/reserved
|
// yields the default guard, which blocks every private/reserved
|
||||||
@@ -70,6 +65,11 @@ func ExportBlockedNetworks() []*net.IPNet {
|
|||||||
return blockedNetworks
|
return blockedNetworks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportBlockedPublicNetworks exposes blockedPublicNetworks.
|
||||||
|
func ExportBlockedPublicNetworks() []*net.IPNet {
|
||||||
|
return blockedPublicNetworks
|
||||||
|
}
|
||||||
|
|
||||||
// ExportIsForwardableHeader exposes isForwardableHeader.
|
// ExportIsForwardableHeader exposes isForwardableHeader.
|
||||||
func ExportIsForwardableHeader(name string) bool {
|
func ExportIsForwardableHeader(name string) bool {
|
||||||
return isForwardableHeader(name)
|
return isForwardableHeader(name)
|
||||||
|
|||||||
+46
-25
@@ -25,8 +25,16 @@ var (
|
|||||||
errNoIPs = errors.New(
|
errNoIPs = errors.New(
|
||||||
"hostname resolved to no IP addresses",
|
"hostname resolved to no IP addresses",
|
||||||
)
|
)
|
||||||
errBlockedIP = errors.New(
|
// ErrBlockedPrivateOrReservedIP reports an address in the
|
||||||
"blocked private, reserved or cloud metadata address",
|
// default blocklist's private and reserved ranges,
|
||||||
|
// blockedNetworks.
|
||||||
|
ErrBlockedPrivateOrReservedIP = errors.New(
|
||||||
|
"blocked private or reserved address",
|
||||||
|
)
|
||||||
|
// errBlockedPublicMetadata reports a public address on the
|
||||||
|
// default blocklist, one in blockedPublicNetworks.
|
||||||
|
errBlockedPublicMetadata = errors.New(
|
||||||
|
"blocked cloud metadata address",
|
||||||
)
|
)
|
||||||
errBlockedMetadata = errors.New(
|
errBlockedMetadata = errors.New(
|
||||||
"blocked link-local or cloud instance metadata " +
|
"blocked link-local or cloud instance metadata " +
|
||||||
@@ -37,22 +45,32 @@ var (
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// blockedNetworks is the default blocklist: the private and
|
// blockedNetworks and blockedPublicNetworks together are the
|
||||||
// reserved IP ranges, plus the public cloud metadata addresses,
|
// default blocklist: the private and reserved IP ranges, plus
|
||||||
// that are blocked to prevent SSRF attacks. An operator can
|
// the public cloud metadata addresses, that are blocked to
|
||||||
// permit specific blocks out of this set with
|
// prevent SSRF attacks. An operator can permit specific blocks
|
||||||
// ALLOWED_EGRESS_CIDRS; see Guard.
|
// out of this set with ALLOWED_EGRESS_CIDRS; see Guard.
|
||||||
//
|
//
|
||||||
// A public address belongs on the default blocklist only if it
|
// blockedNetworks holds the private and reserved IP ranges.
|
||||||
// hands credentials, user data or bootstrap material to whatever
|
|
||||||
// can reach it, without the caller presenting anything. A
|
|
||||||
// provider's other public addresses are not refused, since
|
|
||||||
// reaching them can be legitimate and no list of them could be
|
|
||||||
// complete.
|
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // package-level network list is appropriate here
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
||||||
var blockedNetworks []*net.IPNet
|
var blockedNetworks []*net.IPNet
|
||||||
|
|
||||||
|
// blockedPublicNetworks holds the default blocklist's public
|
||||||
|
// addresses, kept apart from blockedNetworks so that they are
|
||||||
|
// refused as cloud metadata addresses, never as private or
|
||||||
|
// reserved ones.
|
||||||
|
//
|
||||||
|
// A public address belongs on the default blocklist only if it
|
||||||
|
// hands credentials, user data or bootstrap material to whatever
|
||||||
|
// can reach it, without the caller presenting anything; it goes
|
||||||
|
// in this list. A provider's other public addresses are not
|
||||||
|
// refused, since reaching them can be legitimate and no list of
|
||||||
|
// them could be complete.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
||||||
|
var blockedPublicNetworks []*net.IPNet
|
||||||
|
|
||||||
// alwaysBlockedNetworks are the ranges no configuration can
|
// alwaysBlockedNetworks are the ranges no configuration can
|
||||||
// open: the link-local blocks and the cloud instance metadata
|
// open: the link-local blocks and the cloud instance metadata
|
||||||
// endpoints that live outside them. Reaching one is credential
|
// endpoints that live outside them. Reaching one is credential
|
||||||
@@ -88,8 +106,8 @@ var blockedNetworks []*net.IPNet
|
|||||||
// when it clears both halves. Nothing in this list can be
|
// when it clears both halves. Nothing in this list can be
|
||||||
// reopened, so putting a public address here leaves the operator
|
// reopened, so putting a public address here leaves the operator
|
||||||
// no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS
|
// no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS
|
||||||
// exists to remove. Default-block it in blockedNetworks instead,
|
// exists to remove. Default-block it in blockedPublicNetworks
|
||||||
// which an allowlist can override.
|
// instead, which an allowlist can override.
|
||||||
//
|
//
|
||||||
// This is a criterion, not an enumeration of every metadata
|
// This is a criterion, not an enumeration of every metadata
|
||||||
// address in existence.
|
// address in existence.
|
||||||
@@ -130,6 +148,9 @@ func init() {
|
|||||||
"::1/128",
|
"::1/128",
|
||||||
"fc00::/7",
|
"fc00::/7",
|
||||||
"fe80::/10",
|
"fe80::/10",
|
||||||
|
})
|
||||||
|
|
||||||
|
blockedPublicNetworks = mustParseCIDRs([]string{
|
||||||
// Azure WireServer, a public address that serves VM credentials.
|
// Azure WireServer, a public address that serves VM credentials.
|
||||||
"168.63.129.16/32",
|
"168.63.129.16/32",
|
||||||
})
|
})
|
||||||
@@ -225,13 +246,6 @@ func matchesAny(networks []*net.IPNet, ip net.IP) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// isBlockedIP checks whether an IP address falls within
|
|
||||||
// the default blocklist, before any operator allowlist is
|
|
||||||
// considered.
|
|
||||||
func isBlockedIP(ip net.IP) bool {
|
|
||||||
return matchesAny(blockedNetworks, ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard makes every SSRF decision in the process.
|
// Guard makes every SSRF decision in the process.
|
||||||
//
|
//
|
||||||
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
|
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
|
||||||
@@ -332,7 +346,8 @@ func (g *Guard) allows(ip net.IP) bool {
|
|||||||
// consulted, so no configured CIDR reaches link-local or a
|
// consulted, so no configured CIDR reaches link-local or a
|
||||||
// cloud metadata endpoint at a non-public address.
|
// cloud metadata endpoint at a non-public address.
|
||||||
// 2. The allowlist is consulted next, so a listed private
|
// 2. The allowlist is consulted next, so a listed private
|
||||||
// network becomes reachable.
|
// network, or a listed public address on the default
|
||||||
|
// blocklist, becomes reachable.
|
||||||
// 3. Everything else keeps the default blocklist's answer.
|
// 3. Everything else keeps the default blocklist's answer.
|
||||||
func (g *Guard) checkIP(ip net.IP) error {
|
func (g *Guard) checkIP(ip net.IP) error {
|
||||||
if matchesAny(alwaysBlockedNetworks, ip) {
|
if matchesAny(alwaysBlockedNetworks, ip) {
|
||||||
@@ -345,9 +360,15 @@ func (g *Guard) checkIP(ip net.IP) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if isBlockedIP(ip) {
|
if matchesAny(blockedNetworks, ip) {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"target IP %s: %w", ip, errBlockedIP,
|
"target IP %s: %w", ip, ErrBlockedPrivateOrReservedIP,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchesAny(blockedPublicNetworks, ip) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"target IP %s: %w", ip, errBlockedPublicMetadata,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ const (
|
|||||||
metadataIP = "169.254.169.254"
|
metadataIP = "169.254.169.254"
|
||||||
metadataURL = "http://" + metadataIP + "/latest/meta-data/"
|
metadataURL = "http://" + metadataIP + "/latest/meta-data/"
|
||||||
|
|
||||||
|
// linkLocalIPv4 is the IPv4 link-local block, which holds
|
||||||
|
// metadataIP.
|
||||||
|
linkLocalIPv4 = "169.254.0.0/16"
|
||||||
|
|
||||||
// loopbackHookURL is a target on this host: blocked by
|
// loopbackHookURL is a target on this host: blocked by
|
||||||
// default, reachable only once an operator allowlists
|
// default, reachable only once an operator allowlists
|
||||||
// loopback.
|
// loopback.
|
||||||
@@ -237,7 +241,7 @@ func linkLocalRefusedCases() []metadataAlwaysRefusedCase {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "whole link-local block",
|
name: "whole link-local block",
|
||||||
allow: "169.254.0.0/16",
|
allow: linkLocalIPv4,
|
||||||
target: metadataURL,
|
target: metadataURL,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -412,6 +416,9 @@ func TestGuardAllowlist_AzureWireServerReopenable(t *testing.T) {
|
|||||||
"WireServer must be refused by the default blocklist, "+
|
"WireServer must be refused by the default blocklist, "+
|
||||||
"which an allowlist can override",
|
"which an allowlist can override",
|
||||||
)
|
)
|
||||||
|
require.NotErrorIs(t, err, delivery.ErrBlockedPrivateOrReservedIP,
|
||||||
|
"WireServer is public, not private or reserved",
|
||||||
|
)
|
||||||
|
|
||||||
assertDialRefused(t, defaultGuard, target)
|
assertDialRefused(t, defaultGuard, target)
|
||||||
|
|
||||||
@@ -496,7 +503,7 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
|
|||||||
want := []string{
|
want := []string{
|
||||||
// IPv4 link-local: the 169.254.169.254 metadata
|
// IPv4 link-local: the 169.254.169.254 metadata
|
||||||
// service on AWS, Azure and others.
|
// service on AWS, Azure and others.
|
||||||
"169.254.0.0/16",
|
linkLocalIPv4,
|
||||||
// IPv6 link-local.
|
// IPv6 link-local.
|
||||||
"fe80::/10",
|
"fe80::/10",
|
||||||
// AWS IPv6 IMDS, inside the ULA space an operator may
|
// AWS IPv6 IMDS, inside the ULA space an operator may
|
||||||
@@ -526,6 +533,90 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
|
|||||||
assert.Equal(t, want, got)
|
assert.Equal(t, want, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDefaultBlocklist_PinnedSet pins each list of the default
|
||||||
|
// blocklist on its own, the private and reserved ranges in
|
||||||
|
// blockedNetworks and the public addresses in
|
||||||
|
// blockedPublicNetworks, so moving an entry from one list to the
|
||||||
|
// other fails it. For the first address of each entry it then
|
||||||
|
// checks that the default guard refuses it, and that listing the
|
||||||
|
// entry in ALLOWED_EGRESS_CIDRS opens it unless the unconditional
|
||||||
|
// set holds that address.
|
||||||
|
func TestDefaultBlocklist_PinnedSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// public marks an entry of blockedPublicNetworks; every other
|
||||||
|
// entry belongs in blockedNetworks.
|
||||||
|
tests := []struct {
|
||||||
|
cidr string
|
||||||
|
public bool
|
||||||
|
reopenable bool
|
||||||
|
}{
|
||||||
|
{cidr: "127.0.0.0/8", reopenable: true},
|
||||||
|
{cidr: "10.0.0.0/8", reopenable: true},
|
||||||
|
{cidr: "172.16.0.0/12", reopenable: true},
|
||||||
|
{cidr: "192.168.0.0/16", reopenable: true},
|
||||||
|
{cidr: linkLocalIPv4, reopenable: false},
|
||||||
|
{cidr: "0.0.0.0/8", reopenable: true},
|
||||||
|
{cidr: "100.64.0.0/10", reopenable: true},
|
||||||
|
{cidr: "192.0.0.0/24", reopenable: true},
|
||||||
|
{cidr: "192.0.2.0/24", reopenable: true},
|
||||||
|
{cidr: "198.18.0.0/15", reopenable: true},
|
||||||
|
{cidr: "198.51.100.0/24", reopenable: true},
|
||||||
|
{cidr: "203.0.113.0/24", reopenable: true},
|
||||||
|
{cidr: "224.0.0.0/4", reopenable: true},
|
||||||
|
{cidr: "240.0.0.0/4", reopenable: true},
|
||||||
|
{cidr: "::1/128", reopenable: true},
|
||||||
|
{cidr: "fc00::/7", reopenable: true},
|
||||||
|
{cidr: "fe80::/10", reopenable: false},
|
||||||
|
{cidr: "168.63.129.16/32", public: true, reopenable: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPrivate := make([]string, 0, len(tests))
|
||||||
|
wantPublic := make([]string, 0, len(tests))
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if tt.public {
|
||||||
|
wantPublic = append(wantPublic, tt.cidr)
|
||||||
|
} else {
|
||||||
|
wantPrivate = append(wantPrivate, tt.cidr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gotPrivate := make([]string, 0, len(tests))
|
||||||
|
for _, n := range delivery.ExportBlockedNetworks() {
|
||||||
|
gotPrivate = append(gotPrivate, n.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
gotPublic := make([]string, 0, len(tests))
|
||||||
|
for _, n := range delivery.ExportBlockedPublicNetworks() {
|
||||||
|
gotPublic = append(gotPublic, n.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, wantPrivate, gotPrivate, "blockedNetworks")
|
||||||
|
assert.ElementsMatch(t, wantPublic, gotPublic, "blockedPublicNetworks")
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.cidr, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
prefix := netip.MustParsePrefix(tt.cidr)
|
||||||
|
ip := net.IP(prefix.Addr().AsSlice())
|
||||||
|
|
||||||
|
require.Error(t,
|
||||||
|
delivery.NewTestGuard().ExportCheckIP(ip),
|
||||||
|
"the default guard must refuse %s", ip,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := delivery.NewTestGuard(prefix).ExportCheckIP(ip)
|
||||||
|
if tt.reopenable {
|
||||||
|
assert.NoError(t, err, "listing %s must open it", tt.cidr)
|
||||||
|
} else {
|
||||||
|
assert.Error(t, err, "listing %s must not open it", tt.cidr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// requireLoopback fails the test unless rawURL's host is a
|
// requireLoopback fails the test unless rawURL's host is a
|
||||||
// loopback address, so the allowlist test cannot silently stop
|
// loopback address, so the allowlist test cannot silently stop
|
||||||
// exercising a blocked range.
|
// exercising a blocked range.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIsBlockedIP_PrivateRanges(t *testing.T) {
|
func TestGuardCheckIP_PrivateRanges(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -56,12 +56,14 @@ func TestIsBlockedIP_PrivateRanges(t *testing.T) {
|
|||||||
"failed to parse IP %s", tt.ip,
|
"failed to parse IP %s", tt.ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
refused := delivery.NewTestGuard().ExportCheckIP(ip) != nil
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
tt.blocked,
|
tt.blocked,
|
||||||
delivery.ExportIsBlockedIP(ip),
|
refused,
|
||||||
"isBlockedIP(%s) = %v, want %v",
|
"default guard refuses %s = %v, want %v",
|
||||||
tt.ip,
|
tt.ip,
|
||||||
delivery.ExportIsBlockedIP(ip),
|
refused,
|
||||||
tt.blocked,
|
tt.blocked,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1578,11 +1578,22 @@ func (h *Handlers) validateTargetURL(
|
|||||||
"url", delivery.MaskURL(targetURL),
|
"url", delivery.MaskURL(targetURL),
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
http.Error(
|
|
||||||
w,
|
msg := "Invalid target URL: " + err.Error()
|
||||||
"Invalid target URL: "+err.Error(),
|
|
||||||
http.StatusBadRequest,
|
// Only a private or reserved address's refusal says how
|
||||||
)
|
// to allow it. Metadata refusals never do: link-local and
|
||||||
|
// the other unconditional metadata addresses cannot be
|
||||||
|
// opened, and the default blocklist's public addresses,
|
||||||
|
// which listing does open, hand out credentials.
|
||||||
|
if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) {
|
||||||
|
msg += ". Private and reserved addresses are refused " +
|
||||||
|
"by default; the server's ALLOWED_EGRESS_CIDRS " +
|
||||||
|
"setting allows named networks (see \"Allowing " +
|
||||||
|
"egress to your own network\" in the README)."
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, msg, http.StatusBadRequest)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// privateRefusalHint is the sentence that tells an operator a private
|
||||||
|
// destination is refused on purpose, and how to allow one.
|
||||||
|
const privateRefusalHint = "Private and reserved addresses are " +
|
||||||
|
"refused by default; the server's ALLOWED_EGRESS_CIDRS setting " +
|
||||||
|
"allows named networks (see \"Allowing egress to your own " +
|
||||||
|
"network\" in the README)."
|
||||||
|
|
||||||
|
// TestTargetRefusal_PrivateDestinationSaysHowToAllowIt covers both
|
||||||
|
// target types that take a URL, on add and on edit.
|
||||||
|
func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
targetTypes := []database.TargetType{
|
||||||
|
database.TargetTypeHTTP,
|
||||||
|
database.TargetTypeSlack,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, targetType := range targetTypes {
|
||||||
|
t.Run(string(targetType), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
targetsPath := "/hook/" + webhook.ID + "/targets"
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "private")
|
||||||
|
form.Set("type", string(targetType))
|
||||||
|
form.Set("url", editBlockedURL)
|
||||||
|
|
||||||
|
added := serveTarget(
|
||||||
|
env, http.MethodPost, targetsPath, form,
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, added.Code)
|
||||||
|
assert.Contains(
|
||||||
|
t, added.Body.String(), privateRefusalHint,
|
||||||
|
)
|
||||||
|
|
||||||
|
form.Set("url", editOriginalURL)
|
||||||
|
|
||||||
|
created := serveTarget(
|
||||||
|
env, http.MethodPost, targetsPath, form,
|
||||||
|
)
|
||||||
|
require.Equal(
|
||||||
|
t, http.StatusSeeOther, created.Code,
|
||||||
|
created.Body.String(),
|
||||||
|
)
|
||||||
|
|
||||||
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
|
require.Len(t, targets, 1)
|
||||||
|
|
||||||
|
form.Set("url", editBlockedURL)
|
||||||
|
|
||||||
|
edited := submitTargetEdit(
|
||||||
|
env, webhook.ID, targets[0].ID, form,
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, edited.Code)
|
||||||
|
assert.Contains(
|
||||||
|
t, edited.Body.String(), privateRefusalHint,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetRefusal_MetadataDestinationDoesNotSayHowToAllowIt: no
|
||||||
|
// setting opens a link-local address, and Azure's WireServer hands out
|
||||||
|
// VM credentials, so neither refusal points at the setting.
|
||||||
|
func TestTargetRefusal_MetadataDestinationDoesNotSayHowToAllowIt(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
metadataURLs := map[string]string{
|
||||||
|
"link-local": "http://169.254.169.254/latest/meta-data/",
|
||||||
|
"wireserver": "http://168.63.129.16/?comp=versions",
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, metadataURL := range metadataURLs {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "metadata")
|
||||||
|
form.Set("type", string(database.TargetTypeHTTP))
|
||||||
|
form.Set("url", metadataURL)
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodPost,
|
||||||
|
"/hook/"+webhook.ID+"/targets", form,
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
assert.NotContains(
|
||||||
|
t, w.Body.String(), privateRefusalHint,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
//go:build browser
|
|
||||||
|
|
||||||
// This test needs a headless browser, so it is built only with the
|
|
||||||
// browser build tag: `make test` leaves it out, and `make test-browser`
|
|
||||||
// runs it in the browser image that Dockerfile.browser pins.
|
|
||||||
|
|
||||||
package server_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/log"
|
|
||||||
"github.com/chromedp/cdproto/network"
|
|
||||||
"github.com/chromedp/cdproto/runtime"
|
|
||||||
"github.com/chromedp/chromedp"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// browserTimeout bounds everything one test does in the browser.
|
|
||||||
browserTimeout = 60 * time.Second
|
|
||||||
|
|
||||||
// settleTimeout bounds the wait for an element to show or hide.
|
|
||||||
settleTimeout = 5 * time.Second
|
|
||||||
|
|
||||||
// The window size of a phone, narrow enough that the pages show
|
|
||||||
// the mobile menu button instead of the navigation links.
|
|
||||||
phoneWidth = 390
|
|
||||||
phoneHeight = 844
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
|
||||||
// event log in a headless browser, served by the real router and so
|
|
||||||
// under the real Content-Security-Policy, and checks that the pages'
|
|
||||||
// Alpine.js directives work.
|
|
||||||
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx, problems := startBrowser(t)
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
srv := httptest.NewServer(env.router)
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "browser", "browser-password")
|
|
||||||
webhook := env.seedWebhook(t, userID)
|
|
||||||
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
|
|
||||||
target := env.seedTarget(t, webhook.ID)
|
|
||||||
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
|
|
||||||
|
|
||||||
webhookDB, err := env.dbMgr.GetDB(webhook.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
|
|
||||||
&database.DeliveryResult{
|
|
||||||
DeliveryID: dlv.ID,
|
|
||||||
AttemptNum: 1,
|
|
||||||
StatusCode: http.StatusBadGateway,
|
|
||||||
},
|
|
||||||
).Error)
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
|
|
||||||
))
|
|
||||||
|
|
||||||
page := srv.URL + "/hook/" + webhook.ID
|
|
||||||
|
|
||||||
checkAddForms(ctx, t, page)
|
|
||||||
checkTargetType(ctx, t, page+"/events")
|
|
||||||
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
|
|
||||||
checkMobileMenu(ctx, t, page)
|
|
||||||
|
|
||||||
assert.Empty(t, problems(), "the browser reported problems")
|
|
||||||
}
|
|
||||||
|
|
||||||
// startBrowser starts a headless browser for one test. It returns the
|
|
||||||
// context that drives it, and a function listing what the browser
|
|
||||||
// reported going wrong on its pages: console warnings and errors,
|
|
||||||
// which is how Alpine.js reports an expression it cannot run; uncaught
|
|
||||||
// exceptions; and every entry in the browser's own security log, which
|
|
||||||
// is where it reports each script, style, image or request the
|
|
||||||
// Content-Security-Policy refused.
|
|
||||||
//
|
|
||||||
// The browser library finds the browser on PATH. Without one the first
|
|
||||||
// chromedp.Run fails, and with it the test.
|
|
||||||
func startBrowser(t *testing.T) (context.Context, func() []string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
allocCtx, cancelAlloc := chromedp.NewExecAllocator(
|
|
||||||
t.Context(),
|
|
||||||
append(
|
|
||||||
chromedp.DefaultExecAllocatorOptions[:],
|
|
||||||
// Dockerfile.browser runs the test as root, where the
|
|
||||||
// browser's sandbox cannot start.
|
|
||||||
chromedp.NoSandbox,
|
|
||||||
)...,
|
|
||||||
)
|
|
||||||
t.Cleanup(cancelAlloc)
|
|
||||||
|
|
||||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
|
||||||
t.Cleanup(cancel)
|
|
||||||
|
|
||||||
ctx, cancelTimeout := context.WithTimeout(ctx, browserTimeout)
|
|
||||||
t.Cleanup(cancelTimeout)
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
problems []string
|
|
||||||
)
|
|
||||||
|
|
||||||
chromedp.ListenTarget(ctx, func(ev any) {
|
|
||||||
var problem string
|
|
||||||
|
|
||||||
switch ev := ev.(type) {
|
|
||||||
case *runtime.EventConsoleAPICalled:
|
|
||||||
if ev.Type != runtime.APITypeWarning &&
|
|
||||||
ev.Type != runtime.APITypeError {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
args := make([]string, 0, len(ev.Args))
|
|
||||||
for _, arg := range ev.Args {
|
|
||||||
args = append(args, string(arg.Value))
|
|
||||||
}
|
|
||||||
|
|
||||||
problem = strings.Join(args, " ")
|
|
||||||
case *runtime.EventExceptionThrown:
|
|
||||||
problem = ev.ExceptionDetails.Error()
|
|
||||||
case *log.EventEntryAdded:
|
|
||||||
if ev.Entry.Source != log.SourceSecurity {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
problem = ev.Entry.Text
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
problems = append(problems, problem)
|
|
||||||
})
|
|
||||||
|
|
||||||
return ctx, func() []string {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
return slices.Clone(problems)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setCookies gives the browser the cookies for the server at base.
|
|
||||||
func setCookies(base string, cookies []*http.Cookie) chromedp.ActionFunc {
|
|
||||||
return chromedp.ActionFunc(func(ctx context.Context) error {
|
|
||||||
for _, c := range cookies {
|
|
||||||
err := network.SetCookie(c.Name, c.Value).
|
|
||||||
WithURL(base).
|
|
||||||
Do(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set cookie %s: %w", c.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadPage opens url and waits for Alpine.js to start, which it does
|
|
||||||
// by removing every x-cloak attribute. Until then x-cloak hides the
|
|
||||||
// elements Alpine would hide, so a check made earlier proves nothing.
|
|
||||||
func loadPage(url string) chromedp.Tasks {
|
|
||||||
return chromedp.Tasks{
|
|
||||||
chromedp.Navigate(url),
|
|
||||||
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// shown waits up to settleTimeout for the elements matching a CSS
|
|
||||||
// selector or an XPath expression to be rendered, and reports whether
|
|
||||||
// they were. The wait is needed because Alpine.js shows an element on
|
|
||||||
// the next animation frame, not at once.
|
|
||||||
func shown(ctx context.Context, selector string) bool {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
return chromedp.Run(
|
|
||||||
ctx, chromedp.WaitVisible(selector, chromedp.BySearch),
|
|
||||||
) == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// hidden is shown's opposite: it waits for the elements to be hidden.
|
|
||||||
func hidden(ctx context.Context, selector string) bool {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
return chromedp.Run(
|
|
||||||
ctx, chromedp.WaitNotVisible(selector, chromedp.BySearch),
|
|
||||||
) == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// click clicks the element matching an XPath expression.
|
|
||||||
func click(ctx context.Context, t *testing.T, xpath string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, chromedp.Click(xpath, chromedp.BySearch),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkAddForms loads a webhook page and checks that each section's add
|
|
||||||
// form stays hidden until the Add button beside its heading is clicked.
|
|
||||||
func checkAddForms(ctx context.Context, t *testing.T, url string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
||||||
|
|
||||||
sections := []struct{ heading, form string }{
|
|
||||||
{"Entrypoints", `form[action$="/entrypoints"]`},
|
|
||||||
{"Targets", `form[action$="/targets"]`},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, s := range sections {
|
|
||||||
assert.Truef(
|
|
||||||
t, hidden(ctx, s.form),
|
|
||||||
"%s: the add form shows before Add is clicked", s.heading,
|
|
||||||
)
|
|
||||||
|
|
||||||
click(ctx, t, `//h2[text()="`+s.heading+
|
|
||||||
`"]/following-sibling::button`)
|
|
||||||
|
|
||||||
assert.Truef(
|
|
||||||
t, shown(ctx, s.form),
|
|
||||||
"%s: the add form stays hidden when Add is clicked", s.heading,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkTargetType chooses Slack in the open add target form and checks
|
|
||||||
// what the form would then submit: one url field, the Slack one, and
|
|
||||||
// not the HTTP url, headers or timeout, which are hidden and disabled.
|
|
||||||
//
|
|
||||||
// It then opens the page at elsewhere and goes back. The browser loads
|
|
||||||
// the webhook page again and restores the form as it was left, Slack
|
|
||||||
// chosen, without a change event; the form must again show and submit
|
|
||||||
// Slack's fields, not the HTTP ones.
|
|
||||||
func checkTargetType(ctx context.Context, t *testing.T, elsewhere string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
const (
|
|
||||||
chooseSlack = `(() => {
|
|
||||||
const type = document.querySelector('select[name="type"]');
|
|
||||||
type.value = "slack";
|
|
||||||
type.dispatchEvent(new Event("change"));
|
|
||||||
})()`
|
|
||||||
chosen = `document.querySelector('select[name="type"]').value`
|
|
||||||
howLoaded = `performance.getEntriesByType("navigation")[0].type`
|
|
||||||
submitted = `[...new FormData(
|
|
||||||
document.querySelector('form[action$="/targets"]')).keys()]`
|
|
||||||
slackURL = `input[placeholder^="https://hooks.slack.com/"]`
|
|
||||||
httpURL = `input[placeholder="https://example.com/webhook"]`
|
|
||||||
)
|
|
||||||
|
|
||||||
slackFields := strings.Fields("csrf_token name type max_retries url")
|
|
||||||
|
|
||||||
var fields []string
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
chromedp.Evaluate(chooseSlack, nil),
|
|
||||||
chromedp.Evaluate(submitted, &fields),
|
|
||||||
))
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, slackFields, fields,
|
|
||||||
"with Slack chosen, the HTTP fields must not be submitted",
|
|
||||||
)
|
|
||||||
|
|
||||||
var loaded, restored string
|
|
||||||
|
|
||||||
// Going back waits for the load event, after which the browser has
|
|
||||||
// restored the form.
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
loadPage(elsewhere),
|
|
||||||
chromedp.NavigateBack(),
|
|
||||||
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
|
||||||
chromedp.Evaluate(howLoaded, &loaded),
|
|
||||||
chromedp.Evaluate(chosen, &restored),
|
|
||||||
))
|
|
||||||
|
|
||||||
// A page the browser kept in memory and showed again as it was
|
|
||||||
// would prove nothing here.
|
|
||||||
require.Equal(
|
|
||||||
t, "back_forward", loaded,
|
|
||||||
"going back, the browser did not load the page again",
|
|
||||||
)
|
|
||||||
require.Equal(
|
|
||||||
t, "slack", restored,
|
|
||||||
"going back, the browser did not restore the chosen type",
|
|
||||||
)
|
|
||||||
|
|
||||||
click(ctx, t, `//h2[text()="Targets"]/following-sibling::button`)
|
|
||||||
|
|
||||||
assert.True(t, shown(ctx, slackURL),
|
|
||||||
"going back with Slack chosen, the Slack fields are not shown")
|
|
||||||
assert.True(t, hidden(ctx, httpURL),
|
|
||||||
"going back with Slack chosen, the HTTP fields are shown")
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, chromedp.Evaluate(submitted, &fields),
|
|
||||||
))
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, slackFields, fields,
|
|
||||||
"going back with Slack chosen, the HTTP fields must not be submitted",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkEventLog loads the event log and checks that clicking an event's
|
|
||||||
// row expands it, that in there clicking its delivery shows the
|
|
||||||
// delivery's attempts and clicking again hides them, and that clicking
|
|
||||||
// the event's row again collapses it.
|
|
||||||
func checkEventLog(
|
|
||||||
ctx context.Context, t *testing.T, url, eventID, targetName string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// The event's row shows its ID, and its Resubmit form is in the part
|
|
||||||
// that expands. The delivery's row there shows the target's name.
|
|
||||||
eventRow := `//span[text()="` + eventID + `"]`
|
|
||||||
expanded := `form[action$="/resubmit"]`
|
|
||||||
deliveryRow := `//span[text()="` + targetName + `"]`
|
|
||||||
attempt := `//span[text()="Attempt 1"]`
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
||||||
|
|
||||||
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
|
|
||||||
|
|
||||||
click(ctx, t, eventRow)
|
|
||||||
assert.True(t, shown(ctx, expanded), "clicking the event does not expand it")
|
|
||||||
|
|
||||||
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
|
|
||||||
|
|
||||||
click(ctx, t, deliveryRow)
|
|
||||||
assert.True(t, shown(ctx, attempt),
|
|
||||||
"clicking the delivery does not show its attempts")
|
|
||||||
|
|
||||||
click(ctx, t, deliveryRow)
|
|
||||||
assert.True(t, hidden(ctx, attempt),
|
|
||||||
"clicking the delivery again does not hide its attempts")
|
|
||||||
|
|
||||||
click(ctx, t, eventRow)
|
|
||||||
assert.True(t, hidden(ctx, expanded),
|
|
||||||
"clicking the event again does not collapse it")
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkMobileMenu loads a page in a phone-sized window and checks that
|
|
||||||
// the menu button opens and closes the mobile menu.
|
|
||||||
func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// The menu button is the only button directly in the navigation
|
|
||||||
// bar's top row. Profile is a link only the mobile menu has.
|
|
||||||
button := `//nav/div/button`
|
|
||||||
menu := `//nav//a[text()="Profile"]`
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx,
|
|
||||||
chromedp.EmulateViewport(phoneWidth, phoneHeight),
|
|
||||||
loadPage(url),
|
|
||||||
))
|
|
||||||
|
|
||||||
assert.True(t, hidden(ctx, menu), "the mobile menu starts open")
|
|
||||||
|
|
||||||
click(ctx, t, button)
|
|
||||||
assert.True(t, shown(ctx, menu), "the menu button does not open the menu")
|
|
||||||
|
|
||||||
click(ctx, t, button)
|
|
||||||
assert.True(t, hidden(ctx, menu), "the menu button does not close the menu")
|
|
||||||
}
|
|
||||||
@@ -115,8 +115,8 @@ func TestVersion_EnclosingRepositoryIsNotUsed(t *testing.T) {
|
|||||||
require.Equal(t, unknown, runScript(t, inner, nil))
|
require.Equal(t, unknown, runScript(t, inner, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Docker build has no git metadata, so the version arrives as an
|
// An explicit VERSION, such as the Dockerfile's build arg, wins over
|
||||||
// environment override. It wins over anything derivable.
|
// anything derivable.
|
||||||
func TestVersion_EnvironmentOverrideWins(t *testing.T) {
|
func TestVersion_EnvironmentOverrideWins(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -128,8 +128,8 @@ func TestVersion_EnvironmentOverrideWins(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// An empty VERSION is treated as unset rather than stamping an empty
|
// An empty VERSION is treated as unset rather than stamping an empty
|
||||||
// string: the Dockerfile's build arg has a non-empty default, but a
|
// string: a caller exporting VERSION= must not produce a binary
|
||||||
// caller exporting VERSION= must not produce a binary reporting "".
|
// reporting "".
|
||||||
func TestVersion_EmptyOverrideFallsBackToGit(t *testing.T) {
|
func TestVersion_EmptyOverrideFallsBackToGit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -168,8 +168,8 @@ func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A caller can define VERSION as the empty string -- `make build
|
// A caller can define VERSION as the empty string -- `make build
|
||||||
// VERSION=`, or a `--build-arg VERSION=` reaching the Dockerfile's `make
|
// VERSION=`, or the Dockerfile's `make build VERSION="$VERSION"` when no
|
||||||
// build VERSION="$VERSION"`. script/version's own guard does not cover
|
// VERSION build arg was given. script/version's own guard does not cover
|
||||||
// that: the value never passes through the script. Stamping "" would
|
// that: the value never passes through the script. Stamping "" would
|
||||||
// leave the binary reporting no version and the footer on "dev", which
|
// leave the binary reporting no version and the footer on "dev", which
|
||||||
// is the defect this package exists for.
|
// is the defect this package exists for.
|
||||||
@@ -231,7 +231,7 @@ func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) {
|
|||||||
|
|
||||||
require.NotContains(t, dockerfile, "go build",
|
require.NotContains(t, dockerfile, "go build",
|
||||||
"a raw go build bypasses the Makefile's -X flag")
|
"a raw go build bypasses the Makefile's -X flag")
|
||||||
require.Contains(t, dockerfile, "ARG VERSION=")
|
require.Contains(t, dockerfile, "ARG VERSION")
|
||||||
require.Contains(t, dockerfile,
|
require.Contains(t, dockerfile,
|
||||||
`make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`)
|
`make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-5
@@ -1,16 +1,15 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/assets: extract Alpine.js from its npm package tarball, committed
|
# script/assets: extract Alpine.js from its npm package tarball, committed
|
||||||
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The package
|
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The
|
||||||
# is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy
|
# extracted file is not committed. script/test, make build and make dev run
|
||||||
# forbids eval. The extracted file is not committed. script/test, make
|
# this first.
|
||||||
# build and make dev run this first.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
tar -xzOf 3p/alpinejs-csp-3.14.9.tgz package/dist/cdn.min.js \
|
tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \
|
||||||
>static/js/alpine.min.js
|
>static/js/alpine.min.js
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -2,9 +2,9 @@
|
|||||||
# script/docker: build the Docker image tagged with the project name.
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
# The tag comes from script/projectname.
|
# The tag comes from script/projectname.
|
||||||
#
|
#
|
||||||
# .dockerignore excludes .git/, so the builder stage cannot derive the
|
# The version script/version resolves here goes in as the VERSION build
|
||||||
# version itself. It is resolved here, where the checkout is, and passed
|
# arg, which takes precedence over what the build would derive from the
|
||||||
# in as a build arg; without it the image would stamp itself "unknown".
|
# .git in its context.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/test-browser: run the browser test in internal/server. It runs in
|
|
||||||
# Docker: Dockerfile.browser builds the test and runs it in a digest-pinned
|
|
||||||
# headless browser image, so the host needs no browser.
|
|
||||||
#
|
|
||||||
# --no-cache-filter=browser runs the test again even when nothing changed;
|
|
||||||
# it must name the stage in Dockerfile.browser that runs it.
|
|
||||||
# --output=type=cacheonly leaves no image behind to clean up.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
docker build \
|
|
||||||
-f Dockerfile.browser \
|
|
||||||
--no-cache-filter=browser \
|
|
||||||
--progress=plain \
|
|
||||||
--output=type=cacheonly \
|
|
||||||
.
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
+5
-7
@@ -7,18 +7,16 @@
|
|||||||
#
|
#
|
||||||
# Order of precedence:
|
# Order of precedence:
|
||||||
#
|
#
|
||||||
# 1. $VERSION, if set and non-empty. This is how the value reaches a
|
# 1. $VERSION, if set and non-empty: an explicit value, such as the
|
||||||
# build that cannot derive it: .dockerignore excludes .git/, so the
|
# Dockerfile's VERSION build arg.
|
||||||
# builder stage has no git metadata and the Dockerfile takes the
|
|
||||||
# value as a build arg instead.
|
|
||||||
# 2. `git describe --tags --always --dirty` against this checkout. At
|
# 2. `git describe --tags --always --dirty` against this checkout. At
|
||||||
# a clean tagged commit that is exactly the tag; otherwise it
|
# a clean tagged commit that is exactly the tag; otherwise it
|
||||||
# carries the short SHA, the commit distance when a tag is
|
# carries the short SHA, the commit distance when a tag is
|
||||||
# reachable, and a -dirty suffix for uncommitted changes.
|
# reachable, and a -dirty suffix for uncommitted changes.
|
||||||
# 3. "unknown", for a tree with no git metadata and no $VERSION -- a
|
# 3. "unknown", for a tree with no git metadata and no $VERSION -- a
|
||||||
# source tarball, or `docker build .` with no --build-arg. That
|
# source tarball, or a `docker build` with no .git in its context
|
||||||
# case must not fail the build and must not name a tag the tree may
|
# and no VERSION build arg. That case must not fail the build and
|
||||||
# not be at, so it names nothing.
|
# must not name a tag the tree may not be at, so it names nothing.
|
||||||
#
|
#
|
||||||
# The git step insists the enclosing repository is this checkout, not
|
# The git step insists the enclosing repository is this checkout, not
|
||||||
# merely some repository above it: an unpacked tarball sitting inside an
|
# merely some repository above it: an unpacked tarball sitting inside an
|
||||||
|
|||||||
@@ -57,73 +57,3 @@
|
|||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Alpine.js components.
|
|
||||||
//
|
|
||||||
// The pages' Content-Security-Policy forbids eval, so the UI loads
|
|
||||||
// Alpine's CSP build, which cannot run expressions written in the
|
|
||||||
// markup: a directive in templates/ may only name a property or method,
|
|
||||||
// and each x-data names a component registered here. This script runs
|
|
||||||
// before Alpine, whose script tag is deferred, so this listener is in
|
|
||||||
// place when Alpine starts.
|
|
||||||
document.addEventListener("alpine:init", function () {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// Something a click shows and hides: the mobile menu, an add form,
|
|
||||||
// an event in the event log, a delivery's attempts.
|
|
||||||
window.Alpine.data("collapsible", function () {
|
|
||||||
return {
|
|
||||||
open: false,
|
|
||||||
toggle() {
|
|
||||||
this.open = !this.open;
|
|
||||||
},
|
|
||||||
get closed() {
|
|
||||||
return !this.open;
|
|
||||||
},
|
|
||||||
// Turns a downward caret up while open.
|
|
||||||
get caretClass() {
|
|
||||||
return { "rotate-180": this.open };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// The add target form. Only the chosen type's fields show, and the
|
|
||||||
// others are disabled so that the form does not submit them.
|
|
||||||
//
|
|
||||||
// The type is read from the type select when Alpine starts, when the
|
|
||||||
// select changes, and on pageshow. Going back to the page, the
|
|
||||||
// browser restores the type chosen before without a change event,
|
|
||||||
// in some browsers only after Alpine has started, but always before
|
|
||||||
// pageshow.
|
|
||||||
window.Alpine.data("targetForm", function () {
|
|
||||||
return {
|
|
||||||
targetType: "",
|
|
||||||
init() {
|
|
||||||
this.readType();
|
|
||||||
},
|
|
||||||
readType() {
|
|
||||||
this.targetType = this.$root.querySelector(
|
|
||||||
'select[name="type"]'
|
|
||||||
).value;
|
|
||||||
},
|
|
||||||
get isHttp() {
|
|
||||||
return this.targetType === "http";
|
|
||||||
},
|
|
||||||
get isSlack() {
|
|
||||||
return this.targetType === "slack";
|
|
||||||
},
|
|
||||||
get isDatabase() {
|
|
||||||
return this.targetType === "database";
|
|
||||||
},
|
|
||||||
get notHttp() {
|
|
||||||
return !this.isHttp;
|
|
||||||
},
|
|
||||||
get notSlack() {
|
|
||||||
return !this.isSlack;
|
|
||||||
},
|
|
||||||
get notDatabase() {
|
|
||||||
return !this.isDatabase;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{{define "navbar"}}
|
{{define "navbar"}}
|
||||||
<nav class="app-bar" x-data="collapsible">
|
<nav class="app-bar" x-data="{ open: false }">
|
||||||
<div class="max-w-6xl mx-auto flex justify-between items-center">
|
<div class="max-w-6xl mx-auto flex justify-between items-center">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a>
|
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a>
|
||||||
@@ -7,9 +7,9 @@
|
|||||||
|
|
||||||
<!-- Mobile menu button -->
|
<!-- Mobile menu button -->
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<button @click="toggle" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
|
<button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path x-show="closed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||||
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||||
<div class="flex justify-between items-center mt-2">
|
<div class="flex justify-between items-center mt-2">
|
||||||
@@ -28,10 +28,10 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Entrypoints -->
|
<!-- Entrypoints -->
|
||||||
<div class="card" x-data="collapsible">
|
<div class="card">
|
||||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
<h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
|
<h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
|
||||||
<button @click="toggle" class="btn-text text-sm">
|
<button @click="showAddEntrypoint = !showAddEntrypoint" class="btn-text text-sm">
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add entrypoint form -->
|
<!-- Add entrypoint form -->
|
||||||
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
<form method="POST" action="/hook/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
||||||
@@ -87,10 +87,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Targets -->
|
<!-- Targets -->
|
||||||
<div class="card" x-data="collapsible">
|
<div class="card">
|
||||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
||||||
<button @click="toggle" class="btn-text text-sm">
|
<button @click="showAddTarget = !showAddTarget" class="btn-text text-sm">
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -99,42 +99,42 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add target form -->
|
<!-- Add target form -->
|
||||||
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="targetForm" @pageshow.window="readType" class="space-y-3">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
||||||
<select name="type" @change="readType" class="input text-sm w-32">
|
<select name="type" x-model="targetType" class="input text-sm w-32">
|
||||||
<option value="http">HTTP</option>
|
<option value="http">HTTP</option>
|
||||||
<option value="slack">Slack</option>
|
<option value="slack">Slack</option>
|
||||||
<option value="database">Database</option>
|
<option value="database">Database</option>
|
||||||
<option value="log">Log</option>
|
<option value="log">Log</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isHttp">
|
<div x-show="targetType === 'http'">
|
||||||
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="notHttp" class="input text-sm">
|
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm">
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isHttp">
|
<div x-show="targetType === 'http'">
|
||||||
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="notHttp" class="input text-sm"></textarea>
|
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="targetType !== 'http'" class="input text-sm"></textarea>
|
||||||
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isHttp" class="flex gap-2 items-center">
|
<div x-show="targetType === 'http'" class="flex gap-2 items-center">
|
||||||
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
||||||
<input type="number" name="timeout" min="0" max="300" :disabled="notHttp" class="input text-sm w-24">
|
<input type="number" name="timeout" min="0" max="300" :disabled="targetType !== 'http'" class="input text-sm w-24">
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isHttp">
|
<div x-show="targetType === 'http'">
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<label class="text-sm text-gray-700">Max retries:</label>
|
<label class="text-sm text-gray-700">Max retries:</label>
|
||||||
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isSlack">
|
<div x-show="targetType === 'slack'">
|
||||||
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="notSlack" class="input text-sm">
|
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" class="input text-sm">
|
||||||
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isDatabase">
|
<div x-show="targetType === 'database'">
|
||||||
<input type="text" name="expiry" placeholder="never" :disabled="notDatabase" class="input text-sm">
|
<input type="text" name="expiry" placeholder="never" :disabled="targetType !== 'database'" class="input text-sm">
|
||||||
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
|
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Events}}
|
{{range .Events}}
|
||||||
<div class="p-4" x-data="collapsible">
|
<div class="p-4" x-data="{ open: false }">
|
||||||
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
|
<div class="flex items-center justify-between cursor-pointer" @click="open = !open">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="badge-info">{{.Method}}</span>
|
<span class="badge-info">{{.Method}}</span>
|
||||||
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
|
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
|
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
|
||||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,8 +71,8 @@
|
|||||||
<h3 class="text-xs font-medium uppercase tracking-wide text-gray-500">Deliveries</h3>
|
<h3 class="text-xs font-medium uppercase tracking-wide text-gray-500">Deliveries</h3>
|
||||||
<div class="mt-2 divide-y divide-gray-200">
|
<div class="mt-2 divide-y divide-gray-200">
|
||||||
{{range .Deliveries}}
|
{{range .Deliveries}}
|
||||||
<div class="py-2" x-data="collapsible">
|
<div class="py-2" x-data="{ attempts: false }">
|
||||||
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
|
<div class="flex items-center justify-between cursor-pointer" @click="attempts = !attempts">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
|
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
|
||||||
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
|
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
|
||||||
@@ -86,13 +86,13 @@
|
|||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
|
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
|
||||||
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="{ 'rotate-180': attempts }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-show="open" x-cloak class="mt-2 space-y-2">
|
<div x-show="attempts" x-cloak class="mt-2 space-y-2">
|
||||||
{{if .AttemptsOmitted}}
|
{{if .AttemptsOmitted}}
|
||||||
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
|
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user