diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..f6bd79a9 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,28 @@ +changelog: + exclude: + labels: + - ignore-for-release + authors: + - github-actions + categories: + - title: New Features + labels: + - feature + - enhancement + - feat + - title: Bug Fixes + labels: + - bug + - fix + - title: Documentation + labels: + - documentation + - docs + - title: Maintenance + labels: + - chore + - dependencies + - refactor + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..67ee3d80 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,198 @@ +name: release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + version: + description: "Release version, for example v2.8.4" + required: true + type: string + +permissions: + contents: write + +jobs: + test: + env: + GOTOOLCHAIN: local + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22.x" + - name: Test + run: go test -v ./... + + build: + env: + CGO_ENABLED: "0" + GOTOOLCHAIN: local + needs: test + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: freebsd + goarch: 386 + - goos: freebsd + goarch: amd64 + - goos: freebsd + goarch: arm64 + - goos: linux + goarch: 386 + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm + - goos: linux + goarch: arm64 + - goos: openbsd + goarch: 386 + - goos: openbsd + goarch: amd64 + - goos: openbsd + goarch: arm64 + - goos: windows + goarch: 386 + - goos: windows + goarch: amd64 + - goos: windows + goarch: arm64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22.x" + + - name: Resolve version + id: version + shell: bash + run: | + version="${{ github.event.inputs.version || github.ref_name }}" + version="${version#v}" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Build archive + shell: bash + run: | + target="webhook-${{ matrix.goos }}-${{ matrix.goarch }}" + mkdir -p "dist/$target" + + binary="webhook" + if [ "${{ matrix.goos }}" = "windows" ]; then + binary="webhook.exe" + fi + + GOOS="${{ matrix.goos }}" GOARCH="${{ matrix.goarch }}" \ + go build -trimpath -ldflags="-s -w \ + -X main.version=${{ steps.version.outputs.version }} \ + -X main.updateManifestPublicKey=${{ vars.UPDATE_MANIFEST_PUBLIC_KEY }}" \ + -o "dist/$target/$binary" . + + cp LICENSE README.md "dist/$target/" + tar -czf "dist/$target.tar.gz" -C dist "$target" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: webhook-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/*.tar.gz + if-no-files-found: error + + publish: + needs: build + runs-on: ubuntu-latest + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Generate checksums + shell: bash + run: | + cd dist + sha256sum *.tar.gz > checksums.txt + + - name: Generate update manifest + shell: bash + run: | + tag="${{ github.event.inputs.version || github.ref_name }}" + if [[ "$tag" != v* ]]; then + tag="v$tag" + fi + + assets='[]' + for archive in dist/webhook-*.tar.gz; do + name="$(basename "$archive")" + target="${name%.tar.gz}" + platform="${target#webhook-}" + goarch="${platform##*-}" + goos="${platform%-*}" + size="$(stat -c '%s' "$archive")" + sha256="$(sha256sum "$archive" | awk '{print $1}')" + asset="$(jq -n \ + --arg os "$goos" \ + --arg arch "$goarch" \ + --arg name "$name" \ + --argjson size "$size" \ + --arg sha256 "$sha256" \ + '{os:$os, arch:$arch, name:$name, size:$size, sha256:$sha256}')" + assets="$(jq --argjson asset "$asset" '. + [$asset]' <<<"$assets")" + done + + jq -n \ + --arg version "$tag" \ + --arg publishedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg commit "${{ github.sha }}" \ + --arg releaseURL "https://github.com/${{ github.repository }}/releases/tag/$tag" \ + --argjson assets "$assets" \ + '{schemaVersion:1, version:$version, publishedAt:$publishedAt, commit:$commit, releaseURL:$releaseURL, assets:$assets}' \ + > dist/update-manifest.json + + - name: Sign update manifest + shell: bash + env: + UPDATE_SIGNING_PRIVATE_KEY: ${{ secrets.UPDATE_SIGNING_PRIVATE_KEY }} + run: | + if [ -z "$UPDATE_SIGNING_PRIVATE_KEY" ]; then + echo "UPDATE_SIGNING_PRIVATE_KEY is not configured; publishing an unsigned manifest" >&2 + : > dist/update-manifest.json.sig + exit 0 + fi + printf '%s' "$UPDATE_SIGNING_PRIVATE_KEY" > signing-key.pem + trap 'rm -f signing-key.pem dist/update-manifest.sig.bin' EXIT + openssl pkeyutl -sign -rawin \ + -inkey signing-key.pem \ + -in dist/update-manifest.json \ + -out dist/update-manifest.sig.bin + base64 -w 0 dist/update-manifest.sig.bin > dist/update-manifest.json.sig + + - name: Resolve tag + id: tag + shell: bash + run: | + tag="${{ github.event.inputs.version || github.ref_name }}" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Publish GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.tag }} + name: ${{ steps.tag.outputs.tag }} + files: | + dist/*.tar.gz + dist/checksums.txt + dist/update-manifest.json + dist/update-manifest.json.sig + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 9ad22971..c757ec39 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ coverage webhook /test/hookecho build + +.gocache/ +.DS_Store diff --git a/README.md b/README.md index 20dd6a81..6a161f5c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ Webhook - [webhook][w] is a lightweight configurable tool written in Go, that allows you to easily create HTTP endpoints (hooks) on your server, which you can use to execute configured commands. You can also pass data from the HTTP request (such as headers, payload or query variables) to your commands. [webhook][w] also allows you to specify rules which have to be satisfied in order for the hook to be triggered. +[webhook][w] is a lightweight configurable tool written in Go, that allows you to easily create HTTP endpoints (hooks) on your server, which you can use to execute configured commands. You can also pass data from the HTTP request (such as headers, payload or query variables) to your commands. [webhook][w] also allows you to specify rules which have to be satisfied in order for the hook to be triggered. + +Chinese documentation with the fork-specific install, Admin UI, release, and operations guide is available in [中文说明](README_CN.md). For example, if you're using Github or Bitbucket, you can use [webhook][w] to set up a hook that runs a redeploy script for your project on your staging server, whenever you push changes to the master branch of your project. @@ -46,7 +48,22 @@ If you are using Debian linux ("stretch" or later), you can install webhook usin If you are using FreeBSD, you can install webhook using `pkg install webhook`. ### Download prebuilt binaries -Prebuilt binaries for different architectures are available at [GitHub Releases](https://github.com/adnanh/webhook/releases). +Prebuilt binaries for different architectures are available at [GitHub Releases](https://github.com/xtulnx/webhook/releases). + +### One-line install or update +The install scripts download the latest GitHub Release asset for your platform and replace the local binary. + +Linux, macOS, FreeBSD, or OpenBSD: +```bash +curl -fsSL https://raw.githubusercontent.com/xtulnx/webhook/master/scripts/install.sh | sh +``` + +Windows PowerShell: +```powershell +irm https://raw.githubusercontent.com/xtulnx/webhook/master/scripts/install.ps1 | iex +``` + +Set `WEBHOOK_VERSION` to install a specific release tag, `WEBHOOK_REPO` to install from a fork, or `WEBHOOK_INSTALL_DIR` to choose the destination directory. ## Configuration Next step is to define some hooks you want [webhook][w] to serve. @@ -101,6 +118,8 @@ All files are ignored unless they match one of the following criteria: In either case, the given file part will be parsed as JSON and added to the `payload` map. +If you want a hook command to inspect uploaded files directly, enable the `keep-file-environment` hook option. During command execution webhook will expose each uploaded file as `HOOK_FILE_` and the original filename as `HOOK_FILENAME_`, then remove the temporary file once the command exits. Because multipart field names are copied into the environment variable name after uppercasing, prefer names made of letters, numbers, and underscores if the command will read them from a shell script. + ## Templates [webhook][w] can parse the hooks configuration file as a Go template when given the `-template` [CLI parameter](docs/Webhook-Parameters.md). See the [Templates page](docs/Templates.md) for more details on template usage. diff --git a/README_CN.md b/README_CN.md new file mode 100644 index 00000000..e0bfb4aa --- /dev/null +++ b/README_CN.md @@ -0,0 +1,508 @@ +# webhook 中文使用指南 + +`webhook` 是一个用 Go 编写的轻量级 HTTP 回调服务。它可以把请求中的 header、query、payload、文件等数据映射到命令参数或环境变量,并在规则校验通过后执行指定命令。 + +本仓库是从 `adnanh/webhook` fork 而来的增强版本,仓库地址为 [xtulnx/webhook](https://github.com/xtulnx/webhook)。除了上游原有能力外,本版本补充了 YAML 总配置、Admin 管理界面、命令超时、并发限制、反向代理真实 IP、PID 文件、一键安装脚本、GitHub Release 编译发布流程和安全更新检查机制。 + +![Admin 登录界面](images/img01.webp) + +## 主要特性 + +- 支持 JSON/YAML hook 配置。 +- 支持 `-c`、`-config`、`--config` 加载 YAML 总配置文件。 +- 支持 Admin UI 在线管理已加载的 hooks 文件。 +- Admin 登录使用 TOTP 动态验证码,登录后使用短期 JWT 会话。 +- 支持全局和单 hook 的命令执行超时。 +- 支持全局和单 hook 的并发执行限制。 +- 支持反向代理后的真实客户端 IP 提取,适配 `ip-whitelist`。 +- 支持 multipart 上传文件以临时环境变量形式传给命令。 +- 支持 PID 文件、日志文件、热重载、自定义响应头、TLS、Unix socket。 +- 提供 Linux/macOS/BSD 的 shell 一键安装脚本和 Windows PowerShell 一键安装脚本。 +- 提供 GitHub Actions 自动编译、打包、校验和发布脚本。 +- 支持通过 CLI 检查、应用和回滚已签名的 GitHub Release 更新。 + +## 快速安装或更新 + +Linux、macOS、FreeBSD、OpenBSD: + +```bash +curl -fsSL https://raw.githubusercontent.com/xtulnx/webhook/master/scripts/install.sh | sh +``` + +Windows PowerShell: + +```powershell +irm https://raw.githubusercontent.com/xtulnx/webhook/master/scripts/install.ps1 | iex +``` + +安装脚本默认安装最新 GitHub Release,并进行 SHA256 校验。再次执行同一条命令即可更新到最新版本。 + +可通过环境变量调整安装行为: + +```bash +curl -fsSL https://raw.githubusercontent.com/xtulnx/webhook/master/scripts/install.sh | \ + WEBHOOK_VERSION=v2.8.4 WEBHOOK_INSTALL_DIR="$HOME/.local/bin" sh +``` + +PowerShell 示例: + +```powershell +$env:WEBHOOK_VERSION = "v2.8.4" +$env:WEBHOOK_INSTALL_DIR = "$env:USERPROFILE\bin" +irm https://raw.githubusercontent.com/xtulnx/webhook/master/scripts/install.ps1 | iex +``` + +常用变量: + +- `WEBHOOK_REPO`: GitHub 仓库,默认 `xtulnx/webhook`。 +- `WEBHOOK_VERSION`: 版本 tag,默认 `latest`。 +- `WEBHOOK_INSTALL_DIR`: 安装目录。Unix 默认 `/usr/local/bin`,Windows 默认 `%LOCALAPPDATA%\Programs\webhook`。 +- `WEBHOOK_BINARY_NAME`: 安装后的 binary 名称,默认 `webhook` 或 `webhook.exe`。 + +## 从源码构建 + +需要 Go 1.21 或更新版本。 + +```bash +git clone https://github.com/xtulnx/webhook.git +cd webhook +go build +./webhook -version +``` + +运行测试: + +```bash +go test ./... +``` + +## 更新 webhook 程序 + +更新命令默认使用 `xtulnx/webhook`,不需要设置 `repository`。更新状态文件默认放在当前工作目录,因此 `state-dir` 不是必需项;如果该目录不可写,可以显式指定一个可写目录。 + +```bash +# 检查最新版本 +webhook update check + +# 应用更新(会要求确认;脚本或 CI 可加 --yes) +webhook update apply --yes + +# 指定版本或状态目录 +webhook update apply --version v2.8.4 --state-dir /var/lib/webhook/update --yes + +# 使用上一次更新保存的备份回滚 +webhook update rollback --state-dir /var/lib/webhook/update +``` + +Release 清单优先使用内置 Ed25519 公钥验证。若部署环境尚未配置公钥,`check` 仍可工作,但 `apply` 默认拒绝未签名清单;仅在你明确接受 SHA256 校验而不做签名验证时使用 `--allow-unsigned`。 + +更新会下载对应平台的 Release 归档,校验 SHA256 和签名,验证新二进制的版本号后再替换当前文件,并保留 `webhook.previous`。更新不会自动重启正在运行的服务;使用 systemd 时请在更新后执行 `systemctl restart webhook`。Admin 页面只提供认证后的版本检查和状态展示,程序替换仍通过 CLI 执行。 + +生成本地 release 包: + +```bash +make release +make release-windows +``` + +## 最小可用示例 + +创建 hook 文件 `hooks.yaml`: + +```yaml +- id: deploy + execute-command: /usr/local/bin/deploy.sh + command-working-directory: /srv/app + http-methods: + - POST + response-message: Deploy triggered + trigger-rule: + match: + type: value + value: my-secret + parameter: + source: header + name: X-Webhook-Secret +``` + +启动服务: + +```bash +webhook -hooks hooks.yaml -verbose +``` + +触发 hook: + +```bash +curl -X POST \ + -H "X-Webhook-Secret: my-secret" \ + http://127.0.0.1:9000/hooks/deploy +``` + +默认监听地址是 `0.0.0.0:9000`,默认 hook URL 前缀是 `/hooks/`。 + +## 使用 YAML 总配置文件 + +本 fork 新增了总配置文件能力。可以把命令行参数写进 YAML 文件,再用 `-c`、`-config` 或 `--config` 启动。 + +示例 `webhook.yaml`: + +```yaml +ip: 0.0.0.0 +port: 1987 +urlprefix: wh +hooks: + - /etc/webhook/admin.yaml + - /etc/webhook/hooks.yaml +hotreload: true +nopanic: true +verbose: true +logfile: /var/log/webhook.log +pidfile: /var/run/webhook.pid +command-timeout: 30s +max-concurrency: 4 +``` + +启动: + +```bash +webhook -c /etc/webhook/webhook.yaml +``` + +命令行参数优先级高于配置文件。例如: + +```bash +webhook -c /etc/webhook/webhook.yaml -port 9000 -debug +``` + +完整示例可参考 [webhook.yaml.example](webhook.yaml.example)。 + +## Admin 管理界面 + +本 fork 内置 Admin UI 和 API,可在线查看、新增、编辑和删除已加载的 hooks 文件。 + +![Hook 结构化编辑器](images/img02.webp) + +启用方式: + +```yaml +admin: true +admin-path: admin +admin-totp-secret: "BASE32_TOTP_SECRET" +admin-jwt-secret: "replace-with-a-long-random-secret" +admin-session-ttl: 12h +``` + +启动后访问: + +```text +http://127.0.0.1:1987/admin/ +``` + +生成 TOTP Base32 密钥: + +```bash +python3 -c 'import base64, os; print(base64.b32encode(os.urandom(20)).decode().rstrip("="))' +``` + +把生成的密钥配置到 Google Authenticator、Microsoft Authenticator、1Password 等认证器中,然后在 Admin 登录页输入当前 6 位动态码。 + +注意事项: + +- `admin-path` 不能和 `urlprefix` 相同。 +- `admin-totp-secret` 必须是 Base32 编码。 +- `admin-jwt-secret` 必须设置为足够长的随机字符串。 +- 如果启用了 `template: true`,Admin 写入会进入只读模式,避免覆盖模板配置。 +- 生产环境建议放在 HTTPS 或受保护的反向代理后面。 + +![参数、规则与 JSON 预览](images/img03.webp) + +## Hook 配置要点 + +Hook 至少需要 `id` 和 `execute-command`: + +```yaml +- id: example + execute-command: /usr/local/bin/example.sh +``` + +常用字段: + +- `command-working-directory`: 命令工作目录。 +- `pass-arguments-to-command`: 把请求值作为命令参数传入。 +- `pass-environment-to-command`: 把请求值作为环境变量传入。 +- `pass-file-to-command`: 把文件参数传入命令。 +- `parse-parameters-as-json`: 把指定参数按 JSON 解析。 +- `trigger-rule`: 触发规则,支持 `and`、`or`、`not`、`match`。 +- `response-message`: 成功响应文本。 +- `success-http-response-code`: 成功响应状态码。 +- `trigger-rule-mismatch-http-response-code`: 规则不匹配时的状态码。 +- `include-command-output-in-response`: 把命令输出写入响应。 +- `include-command-output-in-response-on-error`: 命令失败时也返回命令输出。 +- `command-timeout`: 覆盖全局命令超时。 +- `max-concurrency`: 覆盖全局并发限制。 +- `keep-file-environment`: 暴露上传文件临时路径给命令。 + +更完整的字段说明请参考 [docs/Hook-Definition.md](docs/Hook-Definition.md)。 + +## 命令超时与并发限制 + +全局命令超时: + +```bash +webhook -hooks hooks.yaml -command-timeout 30s +``` + +单个 hook 覆盖: + +```yaml +- id: slow-task + execute-command: /usr/local/bin/slow-task.sh + command-timeout: 2m +``` + +`0` 表示禁用超时: + +```yaml +- id: no-timeout-task + execute-command: /usr/local/bin/task.sh + command-timeout: "0" +``` + +全局并发限制: + +```bash +webhook -hooks hooks.yaml -max-concurrency 2 +``` + +单个 hook 覆盖: + +```yaml +- id: deploy + execute-command: /usr/local/bin/deploy.sh + max-concurrency: 1 +``` + +当并发达到上限时,请求会返回 `503 Service Unavailable`。 + +## 反向代理与真实 IP + +如果 webhook 部署在 Nginx、Traefik、Caddy 等反向代理后面,直接使用 `RemoteAddr` 会拿到代理 IP,而不是真实客户端 IP。本 fork 支持可信代理配置,配合 `ip-whitelist` 使用。 + +配置示例: + +```yaml +real-ip-header: X-Real-Ip +trusted-proxies: "127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" +``` + +Nginx 示例: + +```nginx +location / { + proxy_pass http://127.0.0.1:1987; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +} +``` + +只有当请求来源属于 `trusted-proxies` 时,webhook 才会信任 `real-ip-header`,防止客户端伪造 IP header 绕过白名单。 + +## 上传文件处理 + +multipart 表单中,符合条件的 JSON 文件或被 `parse-parameters-as-json` 指定的文件会被解析到 payload。 + +如果命令需要直接读取上传文件,可启用: + +```yaml +- id: upload + execute-command: /usr/local/bin/handle-upload.sh + keep-file-environment: true +``` + +字段名为 `pkg` 的上传文件会在命令运行期间暴露为: + +```text +HOOK_FILE_PKG=/tmp/... +HOOK_FILENAME_PKG=original-file-name.tar.gz +``` + +命令结束后临时文件会被清理。建议 multipart 字段名只使用字母、数字和下划线,方便 shell 脚本读取。 + +## HTTPS、日志和 PID 文件 + +HTTPS: + +```bash +webhook -hooks hooks.yaml -secure -cert /etc/webhook/cert.pem -key /etc/webhook/key.pem +``` + +指定 TLS 最低版本: + +```bash +webhook -hooks hooks.yaml -secure -tls-min-version 1.2 +``` + +日志文件: + +```bash +webhook -hooks hooks.yaml -verbose -logfile /var/log/webhook.log +``` + +PID 文件: + +```bash +webhook -hooks hooks.yaml -pidfile /var/run/webhook.pid +``` + +热重载: + +```bash +webhook -hooks hooks.yaml -hotreload +``` + +## systemd 示例 + +`/etc/systemd/system/webhook.service`: + +```ini +[Unit] +Description=Webhook service +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/webhook -c /etc/webhook/webhook.yaml +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +启用并启动: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now webhook +sudo systemctl status webhook +``` + +## GitHub 编译发布流程 + +本仓库新增 [release workflow](.github/workflows/release.yml)。当推送 `v*` tag 时,会自动: + +1. 运行测试。 +2. 为 Linux、macOS、FreeBSD、OpenBSD、Windows 构建多架构 binary。 +3. 打包为 `webhook-{os}-{arch}.tar.gz`。 +4. 生成 `checksums.txt`。 +5. 发布到 GitHub Releases。 + +发布新版本: + +```bash +git tag v2.8.4 +git push origin v2.8.4 +``` + +也可以在 GitHub Actions 页面手动运行 `release` workflow,并填写版本号。 + +### 版本维护建议 + +建议按语义化版本维护 tag: + +- `v2.8.4`: 修复 bug、文档补充、兼容性小改动。 +- `v2.9.0`: 新增功能,但不破坏已有用法。 +- `v3.0.0`: 有破坏性变更,需要用户调整配置或调用方式。 + +发布前建议创建一个专门的版本提交,把版本号、文档和发布相关改动放在一起: + +```bash +git add webhook.go README.md README_CN.md .github scripts +git commit -m "release: prepare v2.8.4" +git tag v2.8.4 +git push origin master +git push origin v2.8.4 +``` + +Release Notes 由 GitHub 根据上一个 tag 到当前 tag 之间的 commit/PR 自动生成。为了让版本说明更清楚,commit 标题建议使用类似 Conventional Commits 的格式: + +```text +feat: add admin hook editor +fix: correct reverse proxy ip whitelist handling +docs: add Chinese operations guide +chore: update release workflow +``` + +如果通过 Pull Request 合并,建议给 PR 打 label,例如 `feature`、`fix`、`documentation`、`chore`。仓库中的 `.github/release.yml` 会按这些 label 对自动 Release Notes 分类。 + +发布前建议确认: + +- `webhook.go` 中默认版本号已更新,或确认 workflow 注入的 tag 版本符合预期。 +- GitHub Actions 对仓库有 `contents: write` 权限。 +- 目标 tag 不要重复使用,除非你明确要覆盖 Release。 + +## 常见问题 + +### 一键安装下载失败 + +检查 GitHub Release 是否已经存在对应平台资产。例如 Linux amd64 需要: + +```text +webhook-linux-amd64.tar.gz +``` + +也可以指定仓库和版本: + +```bash +curl -fsSL https://raw.githubusercontent.com/xtulnx/webhook/master/scripts/install.sh | \ + WEBHOOK_REPO=xtulnx/webhook WEBHOOK_VERSION=v2.8.4 sh +``` + +### Admin 无法登录 + +优先检查: + +- `admin: true` 是否启用。 +- `admin-totp-secret` 是否为 Base32。 +- 认证器时间是否准确。 +- `admin-jwt-secret` 是否为空。 +- 请求路径是否为配置的 `admin-path`。 + +### IP 白名单在反向代理后不生效 + +需要同时配置: + +- 反向代理写入 `X-Real-IP` 或 `X-Forwarded-For`。 +- webhook 设置 `real-ip-header`。 +- webhook 设置 `trusted-proxies`,并包含代理服务器 IP。 + +### Hook 文件无法通过 Admin 修改 + +如果使用 `template: true`,Admin 写入会被禁用。请关闭模板模式,或手动编辑模板源文件。 + +## 目录说明 + +- `webhook.go`: 主入口和 CLI 参数。 +- `config.go`: YAML 总配置加载。 +- `admin.go`、`admin_store.go`、`admin_ui.go`: Admin UI/API 和配置写入。 +- `execution.go`: 命令超时和并发控制。 +- `realip.go`: 反向代理真实 IP 处理。 +- `internal/hook/`: hook 解析、规则判断和请求映射。 +- `internal/middleware/`: HTTP 日志和请求辅助中间件。 +- `internal/pidfile/`: PID 文件处理。 +- `adminui/`: 内嵌 Admin 前端页面。 +- `scripts/`: 一键安装脚本。 +- `.github/workflows/`: GitHub Actions 构建和发布脚本。 + +## 安全建议 + +- 不要提交真实密钥、生产 hook 定义或 TOTP/JWT secret。 +- Admin UI 建议放在 HTTPS 后面,并限制访问来源。 +- `trusted-proxies` 只填写你真正控制的代理地址。 +- `include-command-output-in-response` 可能泄露命令输出,生产环境谨慎开启。 +- hook 执行的脚本要自行做好权限、参数校验和日志记录。 + +## 许可证 + +本项目沿用上游 MIT License。详见 [LICENSE](LICENSE)。 diff --git a/admin.go b/admin.go new file mode 100644 index 00000000..ec336666 --- /dev/null +++ b/admin.go @@ -0,0 +1,481 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "encoding/base32" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/adnanh/webhook/internal/hook" + "github.com/gorilla/mux" +) + +var ( + adminEnabled = flag.Bool("admin", false, "enable the admin UI and API for managing hooks") + adminURLPrefix = flag.String("admin-path", "admin", "url prefix to use for the admin UI and API") + adminTOTPSecret = flag.String("admin-totp-secret", "", "base32 encoded TOTP secret for admin login") + adminJWTSecret = flag.String("admin-jwt-secret", "", "secret used to sign admin JWT sessions") + adminSessionTTL = flag.String("admin-session-ttl", "12h", "lifetime of an admin JWT session") +) + +const adminTokenCookieName = "webhook_admin_token" + +type adminAuthConfig struct { + basePath string + totpSecret []byte + jwtSecret []byte + sessionTTL time.Duration +} + +type adminJWTHeader struct { + Algorithm string `json:"alg"` + Type string `json:"typ"` +} + +type adminJWTClaims struct { + Subject string `json:"sub"` + IssuedAt int64 `json:"iat"` + NotBefore int64 `json:"nbf"` + ExpiresAt int64 `json:"exp"` +} + +type adminLoginRequest struct { + Code string `json:"code"` +} + +type adminHookMutationRequest struct { + File string `json:"file"` + CurrentID string `json:"currentId,omitempty"` + Hook hook.Hook `json:"hook"` +} + +var currentAdminAuth *adminAuthConfig + +func initAdmin() error { + if !*adminEnabled { + return nil + } + + trimmedAdminPath := strings.Trim(strings.TrimSpace(*adminURLPrefix), "/") + if trimmedAdminPath == "" { + return errors.New("admin-path must not be empty") + } + + trimmedHooksPath := strings.Trim(strings.TrimSpace(*hooksURLPrefix), "/") + if trimmedHooksPath == trimmedAdminPath { + return errors.New("admin-path must not match urlprefix") + } + + ttl, err := time.ParseDuration(strings.TrimSpace(*adminSessionTTL)) + if err != nil { + return fmt.Errorf("invalid admin-session-ttl: %w", err) + } + if ttl <= 0 { + return errors.New("admin-session-ttl must be greater than zero") + } + + totpSecret, err := decodeTOTPSecret(*adminTOTPSecret) + if err != nil { + return fmt.Errorf("invalid admin-totp-secret: %w", err) + } + + jwtSecret := strings.TrimSpace(*adminJWTSecret) + if jwtSecret == "" { + return errors.New("admin-jwt-secret must not be empty") + } + + *adminURLPrefix = trimmedAdminPath + currentAdminAuth = &adminAuthConfig{ + basePath: makeBaseURL(adminURLPrefix), + totpSecret: totpSecret, + jwtSecret: []byte(jwtSecret), + sessionTTL: ttl, + } + + return nil +} + +func registerAdminRoutes(r *mux.Router) { + if !*adminEnabled { + return + } + + basePath := currentAdminAuth.basePath + staticHandler := http.StripPrefix(basePath+"/", adminStaticHandler()) + + r.HandleFunc(basePath, adminUIHandler).Methods(http.MethodGet) + r.HandleFunc(basePath+"/", adminUIHandler).Methods(http.MethodGet) + r.HandleFunc(basePath+"/api/auth/login", adminLoginHandler).Methods(http.MethodPost) + r.HandleFunc(basePath+"/api/auth/logout", adminLogoutHandler).Methods(http.MethodPost) + r.HandleFunc(basePath+"/api/config", adminRequireAuth(adminConfigHandler)).Methods(http.MethodGet) + r.HandleFunc(basePath+"/api/update/status", adminRequireAuth(adminUpdateStatusHandler)).Methods(http.MethodGet) + r.HandleFunc(basePath+"/api/update/check", adminRequireAuth(adminUpdateCheckHandler)).Methods(http.MethodPost) + r.HandleFunc(basePath+"/api/hooks", adminRequireAuth(adminCreateHookHandler)).Methods(http.MethodPost) + r.HandleFunc(basePath+"/api/hooks", adminRequireAuth(adminUpdateHookHandler)).Methods(http.MethodPut) + r.HandleFunc(basePath+"/api/hooks", adminRequireAuth(adminDeleteHookHandler)).Methods(http.MethodDelete) + r.PathPrefix(basePath + "/assets/").Handler(staticHandler).Methods(http.MethodGet) +} + +func decodeTOTPSecret(secret string) ([]byte, error) { + normalized := strings.ToUpper(strings.TrimSpace(secret)) + replacer := strings.NewReplacer(" ", "", "-", "", "\n", "", "\r", "", "\t", "", "=", "") + normalized = replacer.Replace(normalized) + + if normalized == "" { + return nil, errors.New("secret is empty") + } + + decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(normalized) + if err == nil { + return decoded, nil + } + + if rem := len(normalized) % 8; rem != 0 { + normalized += strings.Repeat("=", 8-rem) + } + + return base32.StdEncoding.DecodeString(normalized) +} + +func hotpCode(secret []byte, counter uint64, digits int) string { + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], counter) + + mac := hmac.New(sha1.New, secret) + _, _ = mac.Write(buf[:]) + sum := mac.Sum(nil) + + offset := sum[len(sum)-1] & 0x0f + value := (int(sum[offset])&0x7f)<<24 | + int(sum[offset+1])<<16 | + int(sum[offset+2])<<8 | + int(sum[offset+3]) + + mod := 1 + for i := 0; i < digits; i++ { + mod *= 10 + } + + code := value % mod + format := "%0" + strconv.Itoa(digits) + "d" + return fmt.Sprintf(format, code) +} + +func verifyTOTP(secret []byte, code string, now time.Time) bool { + code = strings.TrimSpace(code) + if len(code) != 6 { + return false + } + for _, ch := range code { + if ch < '0' || ch > '9' { + return false + } + } + + counter := now.Unix() / 30 + for offset := int64(-1); offset <= 1; offset++ { + current := counter + offset + if current < 0 { + continue + } + + expected := hotpCode(secret, uint64(current), 6) + if hmac.Equal([]byte(expected), []byte(code)) { + return true + } + } + + return false +} + +func signAdminJWT(now time.Time) (string, error) { + if currentAdminAuth == nil { + return "", errors.New("admin auth is not initialized") + } + + header := adminJWTHeader{ + Algorithm: "HS256", + Type: "JWT", + } + claims := adminJWTClaims{ + Subject: "webhook-admin", + IssuedAt: now.Unix(), + NotBefore: now.Add(-30 * time.Second).Unix(), + ExpiresAt: now.Add(currentAdminAuth.sessionTTL).Unix(), + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return "", err + } + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", err + } + + payload := base64.RawURLEncoding.EncodeToString(headerJSON) + "." + base64.RawURLEncoding.EncodeToString(claimsJSON) + + mac := hmac.New(sha256.New, currentAdminAuth.jwtSecret) + _, _ = mac.Write([]byte(payload)) + signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + + return payload + "." + signature, nil +} + +func parseAdminJWT(token string, now time.Time) (*adminJWTClaims, error) { + if currentAdminAuth == nil { + return nil, errors.New("admin auth is not initialized") + } + + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, errors.New("invalid token format") + } + + signed := parts[0] + "." + parts[1] + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, errors.New("invalid token signature encoding") + } + + mac := hmac.New(sha256.New, currentAdminAuth.jwtSecret) + _, _ = mac.Write([]byte(signed)) + expected := mac.Sum(nil) + if !hmac.Equal(signature, expected) { + return nil, errors.New("invalid token signature") + } + + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, errors.New("invalid token header encoding") + } + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, errors.New("invalid token payload encoding") + } + + var header adminJWTHeader + if err := json.Unmarshal(headerBytes, &header); err != nil { + return nil, errors.New("invalid token header") + } + if header.Algorithm != "HS256" || header.Type != "JWT" { + return nil, errors.New("unsupported token header") + } + + var claims adminJWTClaims + if err := json.Unmarshal(payloadBytes, &claims); err != nil { + return nil, errors.New("invalid token payload") + } + if claims.Subject != "webhook-admin" { + return nil, errors.New("invalid token subject") + } + + nowUnix := now.Unix() + if claims.NotBefore != 0 && nowUnix < claims.NotBefore { + return nil, errors.New("token is not active yet") + } + if claims.ExpiresAt == 0 || nowUnix >= claims.ExpiresAt { + return nil, errors.New("token has expired") + } + + return &claims, nil +} + +func setAdminTokenCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: adminTokenCookieName, + Value: token, + Path: currentAdminAuth.basePath, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Secure: *secure, + MaxAge: int(currentAdminAuth.sessionTTL.Seconds()), + }) +} + +func clearAdminTokenCookie(w http.ResponseWriter) { + path := "/" + if currentAdminAuth != nil { + path = currentAdminAuth.basePath + } + + http.SetCookie(w, &http.Cookie{ + Name: adminTokenCookieName, + Value: "", + Path: path, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Secure: *secure, + MaxAge: -1, + Expires: time.Unix(0, 0), + }) +} + +func adminTokenFromRequest(r *http.Request) string { + authHeader := strings.TrimSpace(r.Header.Get("Authorization")) + if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + return strings.TrimSpace(authHeader[7:]) + } + + cookie, err := r.Cookie(adminTokenCookieName) + if err != nil { + return "" + } + + return cookie.Value +} + +func authenticateAdminRequest(r *http.Request) error { + token := adminTokenFromRequest(r) + if token == "" { + return errors.New("missing admin token") + } + + _, err := parseAdminJWT(token, time.Now()) + return err +} + +func adminRequireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := authenticateAdminRequest(r); err != nil { + writeAdminError(w, http.StatusUnauthorized, "authentication required") + return + } + + next(w, r) + } +} + +func writeAdminJSON(w http.ResponseWriter, status int, payload interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeAdminError(w http.ResponseWriter, status int, message string) { + writeAdminJSON(w, status, map[string]string{"error": message}) +} + +func decodeAdminJSON(r *http.Request, payload interface{}) error { + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + return decoder.Decode(payload) +} + +func adminLoginHandler(w http.ResponseWriter, r *http.Request) { + var loginReq adminLoginRequest + if err := decodeAdminJSON(r, &loginReq); err != nil { + writeAdminError(w, http.StatusBadRequest, "invalid login payload") + return + } + + if !verifyTOTP(currentAdminAuth.totpSecret, loginReq.Code, time.Now()) { + log.Printf("admin login failed from %s", r.RemoteAddr) + writeAdminError(w, http.StatusUnauthorized, "invalid TOTP code") + return + } + + token, err := signAdminJWT(time.Now()) + if err != nil { + writeAdminError(w, http.StatusInternalServerError, "could not create session token") + return + } + + setAdminTokenCookie(w, token) + log.Printf("admin login succeeded from %s", r.RemoteAddr) + writeAdminJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func adminLogoutHandler(w http.ResponseWriter, r *http.Request) { + clearAdminTokenCookie(w) + writeAdminJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func adminConfigHandler(w http.ResponseWriter, r *http.Request) { + writeAdminJSON(w, http.StatusOK, hooksStateSnapshot()) +} + +func adminMutationStatus(err error) int { + switch { + case err == nil: + return http.StatusOK + case errors.Is(err, errAdminReadOnly): + return http.StatusConflict + case errors.Is(err, errUnknownHookFile): + return http.StatusNotFound + case strings.Contains(err.Error(), "was not found"): + return http.StatusNotFound + case strings.Contains(err.Error(), "already defined"): + return http.StatusConflict + default: + return http.StatusBadRequest + } +} + +func adminCreateHookHandler(w http.ResponseWriter, r *http.Request) { + var req adminHookMutationRequest + if err := decodeAdminJSON(r, &req); err != nil { + writeAdminError(w, http.StatusBadRequest, "invalid hook payload") + return + } + + if err := upsertHookInFile(req.File, "", req.Hook); err != nil { + writeAdminError(w, adminMutationStatus(err), err.Error()) + return + } + + writeAdminJSON(w, http.StatusCreated, map[string]bool{"ok": true}) +} + +func adminUpdateHookHandler(w http.ResponseWriter, r *http.Request) { + var req adminHookMutationRequest + if err := decodeAdminJSON(r, &req); err != nil { + writeAdminError(w, http.StatusBadRequest, "invalid hook payload") + return + } + if strings.TrimSpace(req.CurrentID) == "" { + writeAdminError(w, http.StatusBadRequest, "currentId is required") + return + } + + if err := upsertHookInFile(req.File, req.CurrentID, req.Hook); err != nil { + writeAdminError(w, adminMutationStatus(err), err.Error()) + return + } + + writeAdminJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func adminDeleteHookHandler(w http.ResponseWriter, r *http.Request) { + var req adminHookMutationRequest + if err := decodeAdminJSON(r, &req); err != nil { + writeAdminError(w, http.StatusBadRequest, "invalid delete payload") + return + } + if strings.TrimSpace(req.File) == "" { + writeAdminError(w, http.StatusBadRequest, "file is required") + return + } + if strings.TrimSpace(req.CurrentID) == "" { + writeAdminError(w, http.StatusBadRequest, "currentId is required") + return + } + + if err := deleteHookFromFile(req.File, req.CurrentID); err != nil { + writeAdminError(w, adminMutationStatus(err), err.Error()) + return + } + + writeAdminJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} diff --git a/admin_store.go b/admin_store.go new file mode 100644 index 00000000..76566dbf --- /dev/null +++ b/admin_store.go @@ -0,0 +1,359 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/adnanh/webhook/internal/hook" + "github.com/ghodss/yaml" +) + +var ( + loadedHooksMu sync.RWMutex + + errAdminReadOnly = errors.New("admin writes are disabled when -template is enabled") + errUnknownHookFile = errors.New("unknown hooks file") +) + +type adminHooksFile struct { + Path string `json:"path"` + Format string `json:"format"` + Writable bool `json:"writable"` + Hooks hook.Hooks `json:"hooks"` +} + +type adminHooksState struct { + Files []adminHooksFile `json:"files"` + ReadOnly bool `json:"readOnly"` + ReadOnlyReason string `json:"readOnlyReason,omitempty"` +} + +func adminWritesDisabled() bool { + return *asTemplate +} + +func adminReadOnlyReason() string { + if !adminWritesDisabled() { + return "" + } + + return "editing is disabled while webhook is running with -template because the original template source cannot be preserved safely" +} + +func cloneHook(src hook.Hook) hook.Hook { + dst := src + + if src.ResponseHeaders != nil { + dst.ResponseHeaders = append(hook.ResponseHeaders(nil), src.ResponseHeaders...) + } + if src.PassEnvironmentToCommand != nil { + dst.PassEnvironmentToCommand = append([]hook.Argument(nil), src.PassEnvironmentToCommand...) + } + if src.PassArgumentsToCommand != nil { + dst.PassArgumentsToCommand = append([]hook.Argument(nil), src.PassArgumentsToCommand...) + } + if src.PassFileToCommand != nil { + dst.PassFileToCommand = append([]hook.Argument(nil), src.PassFileToCommand...) + } + if src.JSONStringParameters != nil { + dst.JSONStringParameters = append([]hook.Argument(nil), src.JSONStringParameters...) + } + if src.HTTPMethods != nil { + dst.HTTPMethods = append([]string(nil), src.HTTPMethods...) + } + if src.MaxConcurrency != nil { + value := *src.MaxConcurrency + dst.MaxConcurrency = &value + } + + return dst +} + +func cloneHooks(src hook.Hooks) hook.Hooks { + if src == nil { + return nil + } + + dst := make(hook.Hooks, len(src)) + for i := range src { + dst[i] = cloneHook(src[i]) + } + + return dst +} + +func cloneLoadedHooksMapLocked() map[string]hook.Hooks { + dst := make(map[string]hook.Hooks, len(loadedHooksFromFiles)) + for path, hooksInFile := range loadedHooksFromFiles { + dst[path] = cloneHooks(hooksInFile) + } + + return dst +} + +func hooksFilesSnapshot() []string { + loadedHooksMu.RLock() + defer loadedHooksMu.RUnlock() + + return append([]string(nil), []string(hooksFiles)...) +} + +func hooksStateSnapshot() adminHooksState { + loadedHooksMu.RLock() + files := append([]string(nil), []string(hooksFiles)...) + loaded := cloneLoadedHooksMapLocked() + loadedHooksMu.RUnlock() + + state := adminHooksState{ + ReadOnly: adminWritesDisabled(), + ReadOnlyReason: adminReadOnlyReason(), + Files: make([]adminHooksFile, 0, len(files)), + } + + for _, path := range files { + hooksInFile := cloneHooks(loaded[path]) + if hooksInFile == nil { + hooksInFile = hook.Hooks{} + } + sort.Slice(hooksInFile, func(i, j int) bool { + return hooksInFile[i].ID < hooksInFile[j].ID + }) + + state.Files = append(state.Files, adminHooksFile{ + Path: path, + Format: hooksFileFormat(path), + Writable: !state.ReadOnly, + Hooks: hooksInFile, + }) + } + + return state +} + +func hooksFileFormat(path string) string { + switch strings.ToLower(filepath.Ext(path)) { + case ".yaml", ".yml": + return "yaml" + default: + return "json" + } +} + +func marshalHooksFile(path string, hooksInFile hook.Hooks) ([]byte, error) { + var ( + data []byte + err error + ) + + switch hooksFileFormat(path) { + case "yaml": + data, err = yaml.Marshal(hooksInFile) + default: + data, err = json.MarshalIndent(hooksInFile, "", " ") + } + if err != nil { + return nil, err + } + + if len(data) == 0 || data[len(data)-1] != '\n' { + data = append(data, '\n') + } + + return data, nil +} + +func writeHooksFile(path string, hooksInFile hook.Hooks) error { + data, err := marshalHooksFile(path, hooksInFile) + if err != nil { + return err + } + + dir := filepath.Dir(path) + if dir == "" { + dir = "." + } + + tmp, err := ioutil.TempFile(dir, filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + + tmpName := tmp.Name() + defer os.Remove(tmpName) + + mode := os.FileMode(0o644) + if stat, statErr := os.Stat(path); statErr == nil { + mode = stat.Mode() + } + + if err := tmp.Chmod(mode); err != nil { + tmp.Close() + return err + } + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + + if err := tmp.Close(); err != nil { + return err + } + + renameErr := os.Rename(tmpName, path) + if renameErr == nil { + return nil + } + + if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) { + return renameErr + } + + return os.Rename(tmpName, path) +} + +func validateUniqueHookIDs(candidate map[string]hook.Hooks) error { + seen := make(map[string]string) + + for path, hooksInFile := range candidate { + for _, currentHook := range hooksInFile { + id := strings.TrimSpace(currentHook.ID) + if id == "" { + return fmt.Errorf("hook id is required for hooks file %s", path) + } + + if prevPath, ok := seen[id]; ok { + return fmt.Errorf("hook with ID %s is already defined in %s", id, prevPath) + } + + seen[id] = path + } + } + + return nil +} + +func hookIndexByID(hooksInFile hook.Hooks, id string) int { + for i := range hooksInFile { + if hooksInFile[i].ID == id { + return i + } + } + + return -1 +} + +func normalizeAdminHook(current hook.Hook) hook.Hook { + current.ID = strings.TrimSpace(current.ID) + current.ExecuteCommand = strings.TrimSpace(current.ExecuteCommand) + current.CommandWorkingDirectory = strings.TrimSpace(current.CommandWorkingDirectory) + current.CommandTimeout = strings.TrimSpace(current.CommandTimeout) + current.ResponseMessage = strings.TrimSpace(current.ResponseMessage) + + if len(current.HTTPMethods) != 0 { + methods := make([]string, 0, len(current.HTTPMethods)) + for _, method := range current.HTTPMethods { + method = strings.ToUpper(strings.TrimSpace(method)) + if method != "" { + methods = append(methods, method) + } + } + current.HTTPMethods = methods + } + + return current +} + +func upsertHookInFile(path, currentID string, updated hook.Hook) error { + if adminWritesDisabled() { + return errAdminReadOnly + } + + updated = normalizeAdminHook(updated) + if updated.ID == "" { + return errors.New("hook id is required") + } + if err := updated.ValidateExecutionSettings(); err != nil { + return err + } + + loadedHooksMu.Lock() + defer loadedHooksMu.Unlock() + + existingHooks, ok := loadedHooksFromFiles[path] + if !ok { + return fmt.Errorf("%w: %s", errUnknownHookFile, path) + } + + nextHooks := cloneHooks(existingHooks) + if currentID == "" { + if hookIndexByID(nextHooks, updated.ID) != -1 { + return fmt.Errorf("hook with ID %s is already defined", updated.ID) + } + nextHooks = append(nextHooks, updated) + } else { + index := hookIndexByID(nextHooks, currentID) + if index == -1 { + return fmt.Errorf("hook with ID %s was not found in %s", currentID, path) + } + nextHooks[index] = updated + } + + candidate := cloneLoadedHooksMapLocked() + candidate[path] = nextHooks + + if err := validateUniqueHookIDs(candidate); err != nil { + return err + } + if err := writeHooksFile(path, nextHooks); err != nil { + return err + } + + loadedHooksFromFiles[path] = nextHooks + markHookLoadedLocked(path, time.Now()) + return nil +} + +func deleteHookFromFile(path, id string) error { + if adminWritesDisabled() { + return errAdminReadOnly + } + + loadedHooksMu.Lock() + defer loadedHooksMu.Unlock() + + existingHooks, ok := loadedHooksFromFiles[path] + if !ok { + return fmt.Errorf("%w: %s", errUnknownHookFile, path) + } + + index := hookIndexByID(existingHooks, id) + if index == -1 { + return fmt.Errorf("hook with ID %s was not found in %s", id, path) + } + + nextHooks := cloneHooks(existingHooks[:index]) + nextHooks = append(nextHooks, cloneHooks(existingHooks[index+1:])...) + + candidate := cloneLoadedHooksMapLocked() + candidate[path] = nextHooks + + if err := validateUniqueHookIDs(candidate); err != nil { + return err + } + if err := writeHooksFile(path, nextHooks); err != nil { + return err + } + + loadedHooksFromFiles[path] = nextHooks + markHookLoadedLocked(path, time.Now()) + return nil +} diff --git a/admin_test.go b/admin_test.go new file mode 100644 index 00000000..6b0e9271 --- /dev/null +++ b/admin_test.go @@ -0,0 +1,391 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/adnanh/webhook/internal/hook" + "github.com/gorilla/mux" +) + +const testAdminBase32Secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + +func setupAdminTestState(t *testing.T) func() { + t.Helper() + + savedSecure := *secure + savedAsTemplate := *asTemplate + savedAdminEnabled := *adminEnabled + savedAdminAuth := currentAdminAuth + + loadedHooksMu.RLock() + savedHooksFiles := append(hook.HooksFiles(nil), hooksFiles...) + savedLoaded := cloneLoadedHooksMapLocked() + loadedHooksMu.RUnlock() + + decodedSecret, err := decodeTOTPSecret(testAdminBase32Secret) + if err != nil { + t.Fatalf("decode TOTP secret: %v", err) + } + + currentAdminAuth = &adminAuthConfig{ + basePath: "/admin", + totpSecret: decodedSecret, + jwtSecret: []byte("test-jwt-secret"), + sessionTTL: time.Hour, + } + + *secure = false + *asTemplate = false + *adminEnabled = false + + loadedHooksMu.Lock() + loadedHooksFromFiles = make(map[string]hook.Hooks) + hooksFiles = nil + loadedHooksMu.Unlock() + + return func() { + currentAdminAuth = savedAdminAuth + *secure = savedSecure + *asTemplate = savedAsTemplate + *adminEnabled = savedAdminEnabled + + loadedHooksMu.Lock() + loadedHooksFromFiles = savedLoaded + hooksFiles = savedHooksFiles + loadedHooksMu.Unlock() + } +} + +func setLoadedHooksForTest(path string, hooksInFile hook.Hooks) { + loadedHooksMu.Lock() + defer loadedHooksMu.Unlock() + + loadedHooksFromFiles = map[string]hook.Hooks{ + path: cloneHooks(hooksInFile), + } + hooksFiles = hook.HooksFiles{path} +} + +func readHooksFileForTest(t *testing.T, path string) hook.Hooks { + t.Helper() + + var hooksInFile hook.Hooks + if err := hooksInFile.LoadFromFile(path, false); err != nil { + t.Fatalf("load hooks file %s: %v", path, err) + } + + return hooksInFile +} + +func loginCookieForTest(t *testing.T) *http.Cookie { + t.Helper() + + code := hotpCode(currentAdminAuth.totpSecret, uint64(time.Now().Unix()/30), 6) + req := httptest.NewRequest(http.MethodPost, "/admin/api/auth/login", strings.NewReader(`{"code":"`+code+`"}`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + adminLoginHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("login status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + for _, cookie := range rec.Result().Cookies() { + if cookie.Name == adminTokenCookieName { + return cookie + } + } + + t.Fatal("admin login did not issue session cookie") + return nil +} + +func TestVerifyTOTP(t *testing.T) { + secret, err := decodeTOTPSecret(testAdminBase32Secret) + if err != nil { + t.Fatalf("decode TOTP secret: %v", err) + } + + if got := hotpCode(secret, 1, 6); got != "287082" { + t.Fatalf("hotpCode(...) = %q, want %q", got, "287082") + } + + if !verifyTOTP(secret, "287082", time.Unix(59, 0)) { + t.Fatal("verifyTOTP should accept the RFC test vector") + } + + if verifyTOTP(secret, "287083", time.Unix(59, 0)) { + t.Fatal("verifyTOTP should reject an invalid code") + } +} + +func TestAdminJWTRoundTrip(t *testing.T) { + restore := setupAdminTestState(t) + defer restore() + + now := time.Unix(1700000000, 0) + token, err := signAdminJWT(now) + if err != nil { + t.Fatalf("signAdminJWT: %v", err) + } + + claims, err := parseAdminJWT(token, now.Add(10*time.Minute)) + if err != nil { + t.Fatalf("parseAdminJWT: %v", err) + } + + if claims.Subject != "webhook-admin" { + t.Fatalf("claims.Subject = %q, want %q", claims.Subject, "webhook-admin") + } + + if _, err := parseAdminJWT(token, now.Add(2*time.Hour)); err == nil { + t.Fatal("parseAdminJWT should reject expired tokens") + } +} + +func TestAdminConfigRequiresAuth(t *testing.T) { + restore := setupAdminTestState(t) + defer restore() + + handler := adminRequireAuth(adminConfigHandler) + req := httptest.NewRequest(http.MethodGet, "/admin/api/config", nil) + rec := httptest.NewRecorder() + + handler(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } +} + +func TestAdminCRUDHandlers(t *testing.T) { + restore := setupAdminTestState(t) + defer restore() + + tmpDir := t.TempDir() + hooksPath := filepath.Join(tmpDir, "hooks.json") + + initialHooks := hook.Hooks{ + { + ID: "deploy", + ExecuteCommand: "/bin/echo", + ResponseMessage: "ok", + HTTPMethods: []string{"POST"}, + }, + } + + if err := writeHooksFile(hooksPath, initialHooks); err != nil { + t.Fatalf("writeHooksFile: %v", err) + } + setLoadedHooksForTest(hooksPath, initialHooks) + + cookie := loginCookieForTest(t) + + configReq := httptest.NewRequest(http.MethodGet, "/admin/api/config", nil) + configReq.AddCookie(cookie) + configRec := httptest.NewRecorder() + adminRequireAuth(adminConfigHandler)(configRec, configReq) + + if configRec.Code != http.StatusOK { + t.Fatalf("config status = %d, want %d", configRec.Code, http.StatusOK) + } + + var config adminHooksState + if err := json.Unmarshal(configRec.Body.Bytes(), &config); err != nil { + t.Fatalf("decode config response: %v", err) + } + if len(config.Files) != 1 || len(config.Files[0].Hooks) != 1 { + t.Fatalf("unexpected config payload: %+v", config) + } + + createReq := httptest.NewRequest(http.MethodPost, "/admin/api/hooks", strings.NewReader(`{ + "file": "`+hooksPath+`", + "hook": { + "id": "build", + "execute-command": "/usr/bin/env", + "response-message": "created", + "http-methods": [" post ", "get"] + } + }`)) + createReq.Header.Set("Content-Type", "application/json") + createReq.AddCookie(cookie) + createRec := httptest.NewRecorder() + adminRequireAuth(adminCreateHookHandler)(createRec, createReq) + + if createRec.Code != http.StatusCreated { + t.Fatalf("create status = %d, want %d, body=%s", createRec.Code, http.StatusCreated, createRec.Body.String()) + } + + hooksAfterCreate := readHooksFileForTest(t, hooksPath) + if len(hooksAfterCreate) != 2 { + t.Fatalf("hook count after create = %d, want %d", len(hooksAfterCreate), 2) + } + if hooksAfterCreate.Match("build") == nil { + t.Fatal("expected created hook to be written to file") + } + if got := hooksAfterCreate.Match("build").HTTPMethods; len(got) != 2 || got[0] != "POST" || got[1] != "GET" { + t.Fatalf("created hook methods = %#v, want %#v", got, []string{"POST", "GET"}) + } + + updateReq := httptest.NewRequest(http.MethodPut, "/admin/api/hooks", strings.NewReader(`{ + "file": "`+hooksPath+`", + "currentId": "build", + "hook": { + "id": "build-renamed", + "execute-command": "/bin/echo", + "response-message": "updated", + "http-methods": ["PATCH"] + } + }`)) + updateReq.Header.Set("Content-Type", "application/json") + updateReq.AddCookie(cookie) + updateRec := httptest.NewRecorder() + adminRequireAuth(adminUpdateHookHandler)(updateRec, updateReq) + + if updateRec.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d, body=%s", updateRec.Code, http.StatusOK, updateRec.Body.String()) + } + + hooksAfterUpdate := readHooksFileForTest(t, hooksPath) + if hooksAfterUpdate.Match("build") != nil { + t.Fatal("expected old hook ID to be removed after rename") + } + + renamed := hooksAfterUpdate.Match("build-renamed") + if renamed == nil { + t.Fatal("expected renamed hook to be persisted") + } + if renamed.ResponseMessage != "updated" { + t.Fatalf("updated response message = %q, want %q", renamed.ResponseMessage, "updated") + } + + deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/hooks", strings.NewReader(`{ + "file": "`+hooksPath+`", + "currentId": "build-renamed" + }`)) + deleteReq.Header.Set("Content-Type", "application/json") + deleteReq.AddCookie(cookie) + deleteRec := httptest.NewRecorder() + adminRequireAuth(adminDeleteHookHandler)(deleteRec, deleteReq) + + if deleteRec.Code != http.StatusOK { + t.Fatalf("delete status = %d, want %d, body=%s", deleteRec.Code, http.StatusOK, deleteRec.Body.String()) + } + + hooksAfterDelete := readHooksFileForTest(t, hooksPath) + if len(hooksAfterDelete) != 1 { + t.Fatalf("hook count after delete = %d, want %d", len(hooksAfterDelete), 1) + } + if hooksAfterDelete.Match("build-renamed") != nil { + t.Fatal("expected deleted hook to be removed from file") + } +} + +func TestAdminConfigEmptyHooksUsesArray(t *testing.T) { + restore := setupAdminTestState(t) + defer restore() + + hooksPath := filepath.Join(t.TempDir(), "hooks.json") + setLoadedHooksForTest(hooksPath, nil) + + cookie := loginCookieForTest(t) + req := httptest.NewRequest(http.MethodGet, "/admin/api/config", nil) + req.AddCookie(cookie) + + rec := httptest.NewRecorder() + adminRequireAuth(adminConfigHandler)(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("config status = %d, want %d", rec.Code, http.StatusOK) + } + + body := rec.Body.String() + if strings.Contains(body, `"hooks":null`) { + t.Fatalf("config response should not contain null hooks: %s", body) + } + if !strings.Contains(body, `"hooks":[]`) { + t.Fatalf("config response should contain empty hooks array: %s", body) + } +} + +func TestAdminUpdateHookWithSlashIDViaRouter(t *testing.T) { + restore := setupAdminTestState(t) + defer restore() + + *adminEnabled = true + defer func() { + *adminEnabled = false + }() + + tmpDir := t.TempDir() + hooksPath := filepath.Join(tmpDir, "hooks.json") + + initialHooks := hook.Hooks{ + { + ID: "iot/thjk/web", + ExecuteCommand: "/bin/echo", + ResponseMessage: "ok", + HTTPMethods: []string{"POST"}, + }, + } + + if err := writeHooksFile(hooksPath, initialHooks); err != nil { + t.Fatalf("writeHooksFile: %v", err) + } + setLoadedHooksForTest(hooksPath, initialHooks) + + router := mux.NewRouter() + registerAdminRoutes(router) + + cookie := loginCookieForTest(t) + req := httptest.NewRequest(http.MethodPut, "/admin/api/hooks", strings.NewReader(`{ + "file": "`+hooksPath+`", + "currentId": "iot/thjk/web", + "hook": { + "id": "iot/thjk/web", + "execute-command": "/usr/bin/env", + "response-message": "updated slash id" + } + }`)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(cookie) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("slash id update status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updatedHooks := readHooksFileForTest(t, hooksPath) + updated := updatedHooks.Match("iot/thjk/web") + if updated == nil { + t.Fatal("expected slash-id hook to still exist after update") + } + if updated.ExecuteCommand != "/usr/bin/env" { + t.Fatalf("updated execute-command = %q, want %q", updated.ExecuteCommand, "/usr/bin/env") + } +} + +func TestAdminWritesBlockedInTemplateMode(t *testing.T) { + restore := setupAdminTestState(t) + defer restore() + + tmpDir := t.TempDir() + hooksPath := filepath.Join(tmpDir, "hooks.json") + setLoadedHooksForTest(hooksPath, hook.Hooks{}) + + *asTemplate = true + + err := upsertHookInFile(hooksPath, "", hook.Hook{ID: "blocked"}) + if err == nil || !strings.Contains(err.Error(), errAdminReadOnly.Error()) { + t.Fatalf("upsertHookInFile error = %v, want %v", err, errAdminReadOnly) + } +} diff --git a/admin_ui.go b/admin_ui.go new file mode 100644 index 00000000..45b8032d --- /dev/null +++ b/admin_ui.go @@ -0,0 +1,41 @@ +package main + +import ( + "embed" + "html/template" + "io/fs" + "net/http" +) + +//go:embed adminui/index.html adminui/assets/* +var adminUIFiles embed.FS + +var ( + adminIndexTemplate = template.Must(template.ParseFS(adminUIFiles, "adminui/index.html")) + adminStaticFS = mustAdminSub(adminUIFiles, "adminui") +) + +type adminUIData struct { + BasePath string +} + +func mustAdminSub(fsys fs.FS, dir string) fs.FS { + sub, err := fs.Sub(fsys, dir) + if err != nil { + panic(err) + } + + return sub +} + +func adminUIHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + if err := adminIndexTemplate.Execute(w, adminUIData{BasePath: currentAdminAuth.basePath}); err != nil { + http.Error(w, "admin UI render failed", http.StatusInternalServerError) + } +} + +func adminStaticHandler() http.Handler { + return http.FileServer(http.FS(adminStaticFS)) +} diff --git a/admin_update.go b/admin_update.go new file mode 100644 index 00000000..3906a885 --- /dev/null +++ b/admin_update.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "net/http" + "sync" + "time" + + webhookupdate "github.com/adnanh/webhook/internal/update" +) + +type adminUpdateStatus struct { + Enabled bool `json:"enabled"` + Repository string `json:"repository"` + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion,omitempty"` + Available bool `json:"available"` + Verified bool `json:"verified"` + PublishedAt string `json:"publishedAt,omitempty"` + ReleaseURL string `json:"releaseURL,omitempty"` + CheckedAt string `json:"checkedAt,omitempty"` + Error string `json:"error,omitempty"` +} + +var adminUpdateState = struct { + sync.RWMutex + status adminUpdateStatus +}{} + +var checkAdminUpdate = func(ctx context.Context) (webhookupdate.Result, error) { + return newUpdateClient(*updateRepository).Check(ctx, "") +} + +func adminUpdateStatusSnapshot() adminUpdateStatus { + adminUpdateState.RLock() + status := adminUpdateState.status + adminUpdateState.RUnlock() + status.Enabled = *updateEnabled + status.Repository = *updateRepository + status.CurrentVersion = version + return status +} + +func adminUpdateStatusHandler(w http.ResponseWriter, r *http.Request) { + writeAdminJSON(w, http.StatusOK, adminUpdateStatusSnapshot()) +} + +func adminUpdateCheckHandler(w http.ResponseWriter, r *http.Request) { + if !*updateEnabled { + writeAdminError(w, http.StatusForbidden, "update checks are disabled") + return + } + + result, err := checkAdminUpdate(r.Context()) + if err != nil { + adminUpdateState.Lock() + adminUpdateState.status = adminUpdateStatus{ + CurrentVersion: version, + CheckedAt: time.Now().UTC().Format(time.RFC3339Nano), + Error: err.Error(), + } + adminUpdateState.Unlock() + writeAdminError(w, http.StatusBadGateway, "update check failed") + return + } + + status := adminUpdateStatus{ + CurrentVersion: result.CurrentVersion, + LatestVersion: result.LatestVersion, + Available: result.Available, + Verified: result.Verified, + PublishedAt: result.PublishedAt.Format(time.RFC3339Nano), + ReleaseURL: result.ReleaseURL, + CheckedAt: result.CheckedAt.Format(time.RFC3339Nano), + } + adminUpdateState.Lock() + adminUpdateState.status = status + adminUpdateState.Unlock() + writeAdminJSON(w, http.StatusOK, adminUpdateStatusSnapshot()) +} diff --git a/admin_update_test.go b/admin_update_test.go new file mode 100644 index 00000000..081b5b30 --- /dev/null +++ b/admin_update_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + webhookupdate "github.com/adnanh/webhook/internal/update" +) + +func TestAdminUpdateStatusRequiresAuth(t *testing.T) { + restore := setupAdminTestState(t) + defer restore() + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/admin/api/update/status", nil) + adminRequireAuth(adminUpdateStatusHandler)(recorder, request) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized) + } +} + +func TestAdminUpdateCheck(t *testing.T) { + restoreAdmin := setupAdminTestState(t) + defer restoreAdmin() + + savedEnabled := *updateEnabled + savedRepository := *updateRepository + savedCheck := checkAdminUpdate + savedState := adminUpdateStatusSnapshot() + defer func() { + *updateEnabled = savedEnabled + *updateRepository = savedRepository + checkAdminUpdate = savedCheck + adminUpdateState.Lock() + adminUpdateState.status = savedState + adminUpdateState.Unlock() + }() + + *updateEnabled = true + *updateRepository = "xtulnx/webhook" + checkedAt := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC) + checkAdminUpdate = func(context.Context) (webhookupdate.Result, error) { + return webhookupdate.Result{ + CurrentVersion: version, + LatestVersion: "v2.8.4", + Available: true, + Verified: true, + CheckedAt: checkedAt, + }, nil + } + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/admin/api/update/check", nil) + adminUpdateCheckHandler(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String()) + } + var status adminUpdateStatus + if err := json.Unmarshal(recorder.Body.Bytes(), &status); err != nil { + t.Fatal(err) + } + if status.LatestVersion != "v2.8.4" || !status.Available || !status.Verified { + t.Fatalf("unexpected update status: %+v", status) + } +} + +func TestAdminUpdateCheckFailureDoesNotExposeDetails(t *testing.T) { + restoreAdmin := setupAdminTestState(t) + defer restoreAdmin() + + savedEnabled := *updateEnabled + savedCheck := checkAdminUpdate + defer func() { + *updateEnabled = savedEnabled + checkAdminUpdate = savedCheck + }() + *updateEnabled = true + checkAdminUpdate = func(context.Context) (webhookupdate.Result, error) { + return webhookupdate.Result{}, errors.New("private upstream detail") + } + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/admin/api/update/check", nil) + adminUpdateCheckHandler(recorder, request) + if recorder.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadGateway) + } + if recorder.Body.String() == "" { + t.Fatal("expected an error response") + } +} diff --git a/adminui/assets/app.js b/adminui/assets/app.js new file mode 100644 index 00000000..d4e24616 --- /dev/null +++ b/adminui/assets/app.js @@ -0,0 +1,1323 @@ +(function () { + "use strict"; + + var adminConfig = window.__WEBHOOK_ADMIN__ || {}; + var basePath = adminConfig.basePath || ""; + + var sourceOptions = [ + { value: "", label: "Select source / 选择来源" }, + { value: "header", label: "Header / 请求头" }, + { value: "url", label: "URL Query / URL查询参数" }, + { value: "query", label: "Query Alias / 查询别名" }, + { value: "payload", label: "Payload / 请求体" }, + { value: "raw-request-body", label: "Raw Request Body / 原始请求体" }, + { value: "request", label: "Request / 请求信息" }, + { value: "string", label: "Static String / 静态字符串" }, + { value: "entire-payload", label: "Entire Payload / 完整请求体" }, + { value: "entire-query", label: "Entire Query / 完整查询参数" }, + { value: "entire-headers", label: "Entire Headers / 完整请求头" } + ]; + + var matchTypeOptions = [ + { value: "value", label: "Value Equals / 值匹配" }, + { value: "regex", label: "Regex Match / 正则匹配" }, + { value: "payload-hmac-sha1", label: "Payload HMAC SHA1 / 签名验证" }, + { value: "payload-hmac-sha256", label: "Payload HMAC SHA256 / 签名验证" }, + { value: "payload-hmac-sha512", label: "Payload HMAC SHA512 / 签名验证" }, + { value: "payload-hash-sha1", label: "Payload Hash SHA1 (已弃用)" }, + { value: "payload-hash-sha256", label: "Payload Hash SHA256 (已弃用)" }, + { value: "payload-hash-sha512", label: "Payload Hash SHA512 (已弃用)" }, + { value: "ip-whitelist", label: "IP Whitelist / IP白名单" }, + { value: "scalr-signature", label: "Scalr Signature / Scalr签名" } + ]; + + var state = { + files: [], + currentFile: "", + currentHookId: "", + readOnly: false, + readOnlyReason: "", + ruleDraft: null + }; + + function byId(id) { + return document.getElementById(id); + } + + var els = { + loginPanel: byId("loginPanel"), + workspace: byId("workspace"), + loginForm: byId("loginForm"), + totpCode: byId("totpCode"), + loginStatus: byId("loginStatus"), + fileSelect: byId("fileSelect"), + fileMeta: byId("fileMeta"), + hookList: byId("hookList"), + refreshBtn: byId("refreshBtn"), + checkUpdateBtn: byId("checkUpdateBtn"), + currentVersion: byId("currentVersion"), + latestVersion: byId("latestVersion"), + updateStatus: byId("updateStatus"), + newHookBtn: byId("newHookBtn"), + logoutBtn: byId("logoutBtn"), + readOnlyBanner: byId("readOnlyBanner"), + editorFieldset: byId("editorFieldset"), + hookForm: byId("hookForm"), + hookId: byId("hookId"), + executeCommand: byId("executeCommand"), + commandWorkingDirectory: byId("commandWorkingDirectory"), + commandTimeout: byId("commandTimeout"), + httpMethods: byId("httpMethods"), + responseMessage: byId("responseMessage"), + incomingPayloadContentType: byId("incomingPayloadContentType"), + successHttpResponseCode: byId("successHttpResponseCode"), + triggerRuleMismatchHttpResponseCode: byId("triggerRuleMismatchHttpResponseCode"), + maxConcurrency: byId("maxConcurrency"), + captureCommandOutput: byId("captureCommandOutput"), + captureCommandOutputOnError: byId("captureCommandOutputOnError"), + triggerSignatureSoftFailures: byId("triggerSignatureSoftFailures"), + keepFileEnvironment: byId("keepFileEnvironment"), + responseHeadersRows: byId("responseHeadersRows"), + passArgumentsRows: byId("passArgumentsRows"), + passEnvironmentRows: byId("passEnvironmentRows"), + passFileRows: byId("passFileRows"), + parseJSONRows: byId("parseJSONRows"), + addResponseHeaderBtn: byId("addResponseHeaderBtn"), + addPassArgumentBtn: byId("addPassArgumentBtn"), + addPassEnvironmentBtn: byId("addPassEnvironmentBtn"), + addPassFileBtn: byId("addPassFileBtn"), + addParseJSONBtn: byId("addParseJSONBtn"), + ruleEditor: byId("ruleEditor"), + saveBtn: byId("saveBtn"), + deleteBtn: byId("deleteBtn"), + jsonPreview: byId("jsonPreview"), + workspaceStatus: byId("workspaceStatus") + }; + + var collectionDefs = { + responseHeaders: { + jsonKey: "response-headers", + title: "response headers", + emptyText: "暂无响应头。", + container: els.responseHeadersRows, + addButton: els.addResponseHeaderBtn, + fields: [ + { name: "name", label: "Name", type: "text", required: true }, + { name: "value", label: "Value", type: "text" } + ] + }, + passArguments: { + jsonKey: "pass-arguments-to-command", + title: "command arguments", + emptyText: "暂无命令参数。", + container: els.passArgumentsRows, + addButton: els.addPassArgumentBtn, + fields: [ + { name: "source", label: "Source", type: "select", options: sourceOptions, required: true }, + { name: "name", label: "Name", type: "text", required: true } + ] + }, + passEnvironment: { + jsonKey: "pass-environment-to-command", + title: "environment mappings", + emptyText: "暂无环境变量映射。", + container: els.passEnvironmentRows, + addButton: els.addPassEnvironmentBtn, + fields: [ + { name: "source", label: "Source", type: "select", options: sourceOptions, required: true }, + { name: "name", label: "Name", type: "text", required: true }, + { name: "envname", label: "Env Name", type: "text" } + ] + }, + passFile: { + jsonKey: "pass-file-to-command", + title: "file mappings", + emptyText: "暂无文件参数。", + container: els.passFileRows, + addButton: els.addPassFileBtn, + fields: [ + { name: "source", label: "Source", type: "select", options: sourceOptions, required: true }, + { name: "name", label: "Name", type: "text", required: true }, + { name: "envname", label: "Env Name", type: "text" }, + { name: "base64decode", label: "Base64", type: "checkbox" } + ] + }, + parseJSON: { + jsonKey: "parse-parameters-as-json", + title: "JSON parameter mappings", + emptyText: "暂无 JSON 参数映射。", + container: els.parseJSONRows, + addButton: els.addParseJSONBtn, + fields: [ + { name: "source", label: "Source", type: "select", options: sourceOptions, required: true }, + { name: "name", label: "Name", type: "text", required: true } + ] + } + }; + + function clone(value) { + if (value === null || value === undefined) { + return value; + } + + return JSON.parse(JSON.stringify(value)); + } + + function normalizeArray(value) { + return Array.isArray(value) ? value : []; + } + + function normalizeHookFile(file) { + var next = clone(file) || {}; + next.hooks = normalizeArray(next.hooks); + return next; + } + + function normalizeHook(hook) { + var next = clone(hook) || {}; + next["response-headers"] = normalizeArray(next["response-headers"]); + next["pass-arguments-to-command"] = normalizeArray(next["pass-arguments-to-command"]); + next["pass-environment-to-command"] = normalizeArray(next["pass-environment-to-command"]); + next["pass-file-to-command"] = normalizeArray(next["pass-file-to-command"]); + next["parse-parameters-as-json"] = normalizeArray(next["parse-parameters-as-json"]); + next["http-methods"] = normalizeArray(next["http-methods"]); + return next; + } + + function emptyHook() { + return normalizeHook({ + id: "", + "execute-command": "", + "command-working-directory": "", + "command-timeout": "", + "response-message": "", + "http-methods": ["POST"] + }); + } + + function splitCSV(value) { + return String(value || "") + .split(",") + .map(function (item) { + return item.trim().toUpperCase(); + }) + .filter(function (item) { + return item !== ""; + }); + } + + function setStatus(target, message, tone) { + target.textContent = message; + target.className = "status " + (tone ? "status-" + tone : "status-muted"); + } + + function showLogin(message) { + els.workspace.hidden = true; + els.loginPanel.hidden = false; + setStatus(els.loginStatus, message || "请输入 TOTP 动态码。", message ? "error" : "muted"); + } + + function showWorkspace() { + els.loginPanel.hidden = true; + els.workspace.hidden = false; + } + + function request(path, options) { + var fetchOptions = options || {}; + fetchOptions.credentials = "same-origin"; + fetchOptions.headers = fetchOptions.headers || {}; + fetchOptions.headers.Accept = "application/json"; + + return fetch(basePath + path, fetchOptions).then(function (response) { + return response.text().then(function (text) { + var data = null; + if (text) { + try { + data = JSON.parse(text); + } catch (error) { + data = null; + } + } + + if (response.status === 401) { + showLogin("会话已失效,请重新登录。"); + throw new Error("unauthorized"); + } + + if (!response.ok) { + throw new Error(data && data.error ? data.error : "请求失败。"); + } + + return data; + }); + }); + } + + function getCurrentFile() { + for (var i = 0; i < state.files.length; i++) { + if (state.files[i].path === state.currentFile) { + return state.files[i]; + } + } + + return null; + } + + function getCurrentHook() { + var currentFile = getCurrentFile(); + var hooks = currentFile ? normalizeArray(currentFile.hooks) : []; + + for (var i = 0; i < hooks.length; i++) { + if (hooks[i].id === state.currentHookId) { + return hooks[i]; + } + } + + return null; + } + + function createOption(option, selectedValue) { + var el = document.createElement("option"); + el.value = option.value; + el.textContent = option.label; + if (option.value === selectedValue) { + el.selected = true; + } + return el; + } + + function inputValue(el) { + return String(el.value || "").trim(); + } + + function appendEmptyState(container, text) { + var empty = document.createElement("div"); + empty.className = "repeatable-empty"; + empty.textContent = text; + container.appendChild(empty); + } + + function clearEmptyState(container) { + var empty = container.querySelector(".repeatable-empty"); + if (empty) { + empty.remove(); + } + } + + function ensureCollectionState(def) { + if (!def.container.querySelector(".repeatable-row")) { + def.container.innerHTML = ""; + appendEmptyState(def.container, def.emptyText); + } + } + + function createRowField(field, value) { + var wrapper = document.createElement("div"); + + if (field.type === "checkbox") { + wrapper.className = "row-check"; + + var checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = !!value; + checkbox.dataset.field = field.name; + checkbox.title = field.label; + checkbox.setAttribute("aria-label", field.label); + checkbox.addEventListener("change", updatePreview); + wrapper.appendChild(checkbox); + return wrapper; + } + + var label = document.createElement("span"); + label.className = "field-inline-label"; + label.textContent = field.label; + wrapper.appendChild(label); + + var input; + if (field.type === "select") { + input = document.createElement("select"); + field.options.forEach(function (option) { + input.appendChild(createOption(option, value || "")); + }); + } else { + input = document.createElement("input"); + input.type = "text"; + input.value = value || ""; + } + + input.dataset.field = field.name; + input.addEventListener("input", updatePreview); + input.addEventListener("change", updatePreview); + wrapper.appendChild(input); + + return wrapper; + } + + function appendCollectionRow(def, rowData) { + clearEmptyState(def.container); + + var row = document.createElement("div"); + row.className = "repeatable-row columns-" + def.fields.length; + + def.fields.forEach(function (field) { + row.appendChild(createRowField(field, rowData ? rowData[field.name] : "")); + }); + + var remove = document.createElement("button"); + remove.type = "button"; + remove.className = "icon-button"; + remove.textContent = "×"; + remove.title = "删除这一行"; + remove.addEventListener("click", function () { + row.remove(); + ensureCollectionState(def); + updatePreview(); + }); + row.appendChild(remove); + + def.container.appendChild(row); + } + + function renderCollection(def, rows) { + def.container.innerHTML = ""; + + var safeRows = normalizeArray(rows); + if (!safeRows.length) { + appendEmptyState(def.container, def.emptyText); + return; + } + + safeRows.forEach(function (row) { + appendCollectionRow(def, row); + }); + } + + function readCollection(def, errors) { + var items = []; + var rows = def.container.querySelectorAll(".repeatable-row"); + + rows.forEach(function (row, index) { + var item = {}; + var used = false; + + def.fields.forEach(function (field) { + var input = row.querySelector("[data-field='" + field.name + "']"); + if (!input) { + return; + } + + if (field.type === "checkbox") { + if (input.checked) { + item[field.name] = true; + used = true; + } + return; + } + + var value = String(input.value || "").trim(); + if (value !== "") { + item[field.name] = value; + used = true; + } + }); + + if (!used) { + return; + } + + def.fields.forEach(function (field) { + if (!field.required || field.type === "checkbox") { + return; + } + + if (!item[field.name]) { + errors.push("Incomplete " + def.title + " row " + (index + 1) + ": " + field.label + " is required."); + } + }); + + items.push(item); + }); + + return items; + } + + function ruleKind(rule) { + if (!rule) { + return "none"; + } + if (Array.isArray(rule.and)) { + return "and"; + } + if (Array.isArray(rule.or)) { + return "or"; + } + if (rule.not) { + return "not"; + } + if (rule.match) { + return "match"; + } + return "none"; + } + + function createRule(kind) { + switch (kind) { + case "and": + return { and: [createRule("match")] }; + case "or": + return { or: [createRule("match")] }; + case "not": + return { not: createRule("match") }; + case "match": + return { + match: { + type: "value", + parameter: { + source: "", + name: "" + }, + value: "" + } + }; + default: + return null; + } + } + + function ensureMatch(rule) { + if (!rule.match) { + rule.match = createRule("match").match; + } + if (!rule.match.parameter) { + rule.match.parameter = { source: "", name: "" }; + } + return rule.match; + } + + function isSignatureRule(type) { + return ( + type === "payload-hmac-sha1" || + type === "payload-hmac-sha256" || + type === "payload-hmac-sha512" || + type === "payload-hash-sha1" || + type === "payload-hash-sha256" || + type === "payload-hash-sha512" + ); + } + + function needsParameter(type) { + return type !== "ip-whitelist" && type !== "scalr-signature"; + } + + function createLabeledField(labelText, control) { + var wrapper = document.createElement("div"); + var label = document.createElement("label"); + label.textContent = labelText; + wrapper.appendChild(label); + wrapper.appendChild(control); + return wrapper; + } + + function createTextInput(value, placeholder, onChange) { + var input = document.createElement("input"); + input.type = "text"; + input.value = value || ""; + input.placeholder = placeholder || ""; + input.addEventListener("input", function () { + onChange(input.value); + updatePreview(); + }); + return input; + } + + function createSelectInput(options, value, onChange) { + var select = document.createElement("select"); + options.forEach(function (option) { + select.appendChild(createOption(option, value)); + }); + select.addEventListener("change", function () { + onChange(select.value); + }); + return select; + } + + function renderMatchRule(card, rule) { + var match = ensureMatch(rule); + var fields = document.createElement("div"); + fields.className = "grid grid-2"; + + var typeSelect = createSelectInput(matchTypeOptions, match.type || "value", function (nextType) { + match.type = nextType; + renderRuleEditor(); + updatePreview(); + }); + fields.appendChild(createLabeledField("Match Type", typeSelect)); + + if (needsParameter(match.type)) { + var sourceSelect = createSelectInput(sourceOptions, match.parameter ? match.parameter.source : "", function (nextSource) { + ensureMatch(rule).parameter.source = nextSource; + updatePreview(); + }); + fields.appendChild(createLabeledField("Parameter Source", sourceSelect)); + + var nameInput = createTextInput(match.parameter ? match.parameter.name : "", "e.g. X-Hub-Signature-256", function (nextName) { + ensureMatch(rule).parameter.name = nextName; + }); + fields.appendChild(createLabeledField("Parameter Name", nameInput)); + } + + if (match.type === "value") { + fields.appendChild(createLabeledField("Expected Value", createTextInput(match.value, "main", function (nextValue) { + ensureMatch(rule).value = nextValue; + }))); + } + + if (match.type === "regex") { + fields.appendChild(createLabeledField("Regex", createTextInput(match.regex, "^refs/heads/main$", function (nextRegex) { + ensureMatch(rule).regex = nextRegex; + }))); + } + + if (isSignatureRule(match.type) || match.type === "scalr-signature") { + fields.appendChild(createLabeledField("Secret", createTextInput(match.secret, "shared secret", function (nextSecret) { + ensureMatch(rule).secret = nextSecret; + }))); + } + + if (match.type === "ip-whitelist") { + fields.appendChild(createLabeledField("IP Range (IP白名单)", createTextInput(match["ip-range"] || match.ipRange, "192.168.1.0/24 10.0.0.1", function (nextRange) { + ensureMatch(rule)["ip-range"] = nextRange; + }))); + var hint = document.createElement("div"); + hint.className = "field-hint"; + hint.textContent = "支持 CIDR 和单 IP,空格分隔多个。若经过反向代理,需配置 --trusted-proxies 和 --real-ip-header 才能获取真实客户端 IP。"; + fields.appendChild(hint); + } + + card.appendChild(fields); + } + + function renderRuleCard(rule, depth, replaceRule, removeRule, isRoot) { + var card = document.createElement("div"); + card.className = "rule-card"; + card.dataset.depth = String(Math.min(depth, 3)); + + var head = document.createElement("div"); + head.className = "rule-head"; + + var title = document.createElement("strong"); + title.textContent = isRoot ? "Root Rule" : "Rule"; + head.appendChild(title); + + var controls = document.createElement("div"); + controls.className = "toolbar-row"; + + var kindSelect = createSelectInput( + [ + { value: "none", label: "No Rule" }, + { value: "match", label: "Match" }, + { value: "and", label: "And" }, + { value: "or", label: "Or" }, + { value: "not", label: "Not" } + ], + ruleKind(rule), + function (nextKind) { + replaceRule(createRule(nextKind)); + renderRuleEditor(); + updatePreview(); + } + ); + controls.appendChild(kindSelect); + + if (removeRule) { + var remove = document.createElement("button"); + remove.type = "button"; + remove.className = "icon-button"; + remove.textContent = "×"; + remove.title = "删除这一条规则"; + remove.addEventListener("click", function () { + removeRule(); + renderRuleEditor(); + updatePreview(); + }); + controls.appendChild(remove); + } + + head.appendChild(controls); + card.appendChild(head); + + var kind = ruleKind(rule); + if (kind === "none") { + var note = document.createElement("div"); + note.className = "rule-note"; + note.textContent = "当前没有启用触发规则。"; + card.appendChild(note); + return card; + } + + if (kind === "match") { + renderMatchRule(card, rule); + return card; + } + + if (kind === "not") { + if (!rule.not) { + rule.not = createRule("match"); + } + + var single = document.createElement("div"); + single.className = "rule-children"; + single.appendChild( + renderRuleCard( + rule.not, + depth + 1, + function (nextRule) { + rule.not = nextRule; + }, + null, + false + ) + ); + card.appendChild(single); + return card; + } + + var listKey = kind; + if (!Array.isArray(rule[listKey])) { + rule[listKey] = []; + } + + var children = document.createElement("div"); + children.className = "rule-children"; + + if (!rule[listKey].length) { + var empty = document.createElement("div"); + empty.className = "rule-note"; + empty.textContent = "当前没有子规则。"; + children.appendChild(empty); + } else { + rule[listKey].forEach(function (childRule, index) { + children.appendChild( + renderRuleCard( + childRule, + depth + 1, + function (nextRule) { + rule[listKey][index] = nextRule; + }, + function () { + rule[listKey].splice(index, 1); + }, + false + ) + ); + }); + } + + card.appendChild(children); + + var addChild = document.createElement("button"); + addChild.type = "button"; + addChild.className = "ghost"; + addChild.textContent = "添加子规则"; + addChild.addEventListener("click", function () { + rule[listKey].push(createRule("match")); + renderRuleEditor(); + updatePreview(); + }); + card.appendChild(addChild); + + return card; + } + + function renderRuleEditor() { + els.ruleEditor.innerHTML = ""; + els.ruleEditor.appendChild( + renderRuleCard( + state.ruleDraft, + 0, + function (nextRule) { + state.ruleDraft = nextRule; + }, + null, + true + ) + ); + } + + function setFormValue(el, value) { + el.value = value || ""; + } + + function renderCurrentHook() { + var hook = normalizeHook(getCurrentHook() || emptyHook()); + + setFormValue(els.hookId, hook.id); + setFormValue(els.executeCommand, hook["execute-command"]); + setFormValue(els.commandWorkingDirectory, hook["command-working-directory"]); + setFormValue(els.commandTimeout, hook["command-timeout"]); + setFormValue(els.httpMethods, normalizeArray(hook["http-methods"]).join(", ")); + setFormValue(els.responseMessage, hook["response-message"]); + setFormValue(els.incomingPayloadContentType, hook["incoming-payload-content-type"]); + setFormValue(els.successHttpResponseCode, hook["success-http-response-code"] || ""); + setFormValue(els.triggerRuleMismatchHttpResponseCode, hook["trigger-rule-mismatch-http-response-code"] || ""); + setFormValue(els.maxConcurrency, hook["max-concurrency"] === 0 ? "0" : (hook["max-concurrency"] || "")); + + els.captureCommandOutput.checked = !!hook["include-command-output-in-response"]; + els.captureCommandOutputOnError.checked = !!hook["include-command-output-in-response-on-error"]; + els.triggerSignatureSoftFailures.checked = !!hook["trigger-signature-soft-failures"]; + els.keepFileEnvironment.checked = !!hook["keep-file-environment"]; + + renderCollection(collectionDefs.responseHeaders, hook["response-headers"]); + renderCollection(collectionDefs.passArguments, hook["pass-arguments-to-command"]); + renderCollection(collectionDefs.passEnvironment, hook["pass-environment-to-command"]); + renderCollection(collectionDefs.passFile, hook["pass-file-to-command"]); + renderCollection(collectionDefs.parseJSON, hook["parse-parameters-as-json"]); + + state.ruleDraft = clone(hook["trigger-rule"]) || null; + renderRuleEditor(); + updatePreview(); + } + + function renderFileOptions() { + els.fileSelect.innerHTML = ""; + els.fileSelect.disabled = state.files.length === 0; + + if (!state.files.length) { + var empty = document.createElement("option"); + empty.value = ""; + empty.textContent = "No hooks file"; + els.fileSelect.appendChild(empty); + return; + } + + state.files.forEach(function (file) { + var option = document.createElement("option"); + option.value = file.path; + option.textContent = file.path; + if (file.path === state.currentFile) { + option.selected = true; + } + els.fileSelect.appendChild(option); + }); + } + + function renderFileMeta() { + var currentFile = getCurrentFile(); + if (!currentFile) { + els.fileMeta.textContent = "暂无可管理的 hooks 文件。"; + return; + } + + els.fileMeta.textContent = + "格式: " + + String(currentFile.format || "json").toUpperCase() + + " | hooks: " + + normalizeArray(currentFile.hooks).length + + " | 路径: " + + currentFile.path; + } + + function renderHookList() { + els.hookList.innerHTML = ""; + + var currentFile = getCurrentFile(); + var hooks = currentFile ? normalizeArray(currentFile.hooks) : []; + + if (!hooks.length) { + appendEmptyState(els.hookList, "当前文件还没有 hook,可以直接点击“新建”。"); + return; + } + + hooks.forEach(function (hookItem) { + var button = document.createElement("button"); + button.type = "button"; + button.className = "hook-item" + (hookItem.id === state.currentHookId ? " active" : ""); + button.dataset.id = hookItem.id; + + var methods = normalizeArray(hookItem["http-methods"]).length ? hookItem["http-methods"].join(", ") : "ALL"; + var command = hookItem["execute-command"] || "(no execute-command)"; + + var title = document.createElement("strong"); + title.textContent = hookItem.id || "(no id)"; + button.appendChild(title); + + var subtitle = document.createElement("small"); + subtitle.textContent = methods + " · " + command; + button.appendChild(subtitle); + + button.addEventListener("click", function () { + state.currentHookId = hookItem.id; + renderHookList(); + renderCurrentHook(); + setStatus(els.workspaceStatus, "已载入 hook “" + hookItem.id + "”。", "muted"); + }); + + els.hookList.appendChild(button); + }); + } + + function applyReadOnlyState() { + var hasFiles = state.files.length > 0; + var canEdit = hasFiles && !state.readOnly; + + if (state.readOnly) { + els.readOnlyBanner.hidden = false; + els.readOnlyBanner.textContent = state.readOnlyReason || "当前运行模式为只读。"; + } else { + els.readOnlyBanner.hidden = true; + els.readOnlyBanner.textContent = ""; + } + + els.editorFieldset.disabled = !canEdit; + els.newHookBtn.disabled = !canEdit; + els.saveBtn.disabled = !canEdit; + els.deleteBtn.disabled = !canEdit || !state.currentHookId; + } + + function renderAll() { + renderFileOptions(); + renderFileMeta(); + renderHookList(); + renderCurrentHook(); + applyReadOnlyState(); + } + + function parseOptionalInt(value, label, errors) { + var trimmed = String(value || "").trim(); + if (trimmed === "") { + return null; + } + + var parsed = parseInt(trimmed, 10); + if (isNaN(parsed)) { + errors.push(label + " must be a valid integer."); + return null; + } + + return parsed; + } + + function cleanRule(rule, errors, pathLabel) { + var kind = ruleKind(rule); + if (kind === "none") { + return null; + } + + if (kind === "match") { + var match = clone((rule && rule.match) || {}); + var type = match.type || "value"; + var cleanedMatch = { type: type }; + + if (needsParameter(type)) { + var parameter = clone(match.parameter) || {}; + parameter.source = String(parameter.source || "").trim(); + parameter.name = String(parameter.name || "").trim(); + if (!parameter.source || !parameter.name) { + errors.push(pathLabel + " requires parameter source and parameter name."); + } else { + cleanedMatch.parameter = parameter; + } + } + + if (type === "value") { + var expectedValue = String(match.value || "").trim(); + if (!expectedValue) { + errors.push(pathLabel + " requires a comparison value."); + } else { + cleanedMatch.value = expectedValue; + } + } else if (type === "regex") { + var regex = String(match.regex || "").trim(); + if (!regex) { + errors.push(pathLabel + " requires a regex."); + } else { + cleanedMatch.regex = regex; + } + } else if (isSignatureRule(type) || type === "scalr-signature") { + var secret = String(match.secret || "").trim(); + if (!secret) { + errors.push(pathLabel + " requires a secret."); + } else { + cleanedMatch.secret = secret; + } + } else if (type === "ip-whitelist") { + var ipRange = String(match["ip-range"] || match.ipRange || "").trim(); + if (!ipRange) { + errors.push(pathLabel + " requires an IP range."); + } else { + cleanedMatch["ip-range"] = ipRange; + } + } + + return { match: cleanedMatch }; + } + + if (kind === "not") { + var childRule = cleanRule(rule.not, errors, pathLabel + " > not"); + if (!childRule) { + errors.push(pathLabel + " requires a nested rule."); + return null; + } + return { not: childRule }; + } + + var key = kind; + var children = normalizeArray(rule[key]).map(function (child, index) { + return cleanRule(child, errors, pathLabel + " > child " + (index + 1)); + }).filter(function (child) { + return !!child; + }); + + if (!children.length) { + errors.push(pathLabel + " requires at least one child rule."); + return null; + } + + var composite = {}; + composite[key] = children; + return composite; + } + + function buildHookFromForm() { + var errors = []; + var hook = {}; + + var id = inputValue(els.hookId); + if (!id) { + errors.push("Hook ID is required."); + } else { + hook.id = id; + } + + var executeCommand = inputValue(els.executeCommand); + if (!executeCommand) { + errors.push("Execute Command is required."); + } else { + hook["execute-command"] = executeCommand; + } + + var workingDirectory = inputValue(els.commandWorkingDirectory); + if (workingDirectory) { + hook["command-working-directory"] = workingDirectory; + } + + var commandTimeout = inputValue(els.commandTimeout); + if (commandTimeout) { + hook["command-timeout"] = commandTimeout; + } + + var methods = splitCSV(els.httpMethods.value); + if (methods.length) { + hook["http-methods"] = methods; + } + + var responseMessage = inputValue(els.responseMessage); + if (responseMessage) { + hook["response-message"] = responseMessage; + } + + var contentType = inputValue(els.incomingPayloadContentType); + if (contentType) { + hook["incoming-payload-content-type"] = contentType; + } + + var successCode = parseOptionalInt(els.successHttpResponseCode.value, "Success HTTP Code", errors); + if (successCode !== null) { + hook["success-http-response-code"] = successCode; + } + + var mismatchCode = parseOptionalInt(els.triggerRuleMismatchHttpResponseCode.value, "Rule Mismatch HTTP Code", errors); + if (mismatchCode !== null) { + hook["trigger-rule-mismatch-http-response-code"] = mismatchCode; + } + + var maxConcurrency = parseOptionalInt(els.maxConcurrency.value, "Max Concurrency", errors); + if (maxConcurrency !== null) { + hook["max-concurrency"] = maxConcurrency; + } + + if (els.captureCommandOutput.checked) { + hook["include-command-output-in-response"] = true; + } + if (els.captureCommandOutputOnError.checked) { + hook["include-command-output-in-response-on-error"] = true; + } + if (els.triggerSignatureSoftFailures.checked) { + hook["trigger-signature-soft-failures"] = true; + } + if (els.keepFileEnvironment.checked) { + hook["keep-file-environment"] = true; + } + + Object.keys(collectionDefs).forEach(function (key) { + var def = collectionDefs[key]; + var items = readCollection(def, errors); + if (items.length) { + hook[def.jsonKey] = items; + } + }); + + var cleanedRule = cleanRule(state.ruleDraft, errors, "Trigger rule"); + if (cleanedRule) { + hook["trigger-rule"] = cleanedRule; + } + + return { + hook: hook, + errors: errors + }; + } + + function updatePreview() { + var result = buildHookFromForm(); + var preview = JSON.stringify(result.hook, null, 2); + + if (result.errors.length) { + els.jsonPreview.textContent = "// Validation issues\n// " + result.errors.join("\n// ") + "\n\n" + preview; + return; + } + + els.jsonPreview.textContent = preview; + } + + function loadConfig(preferredHookId) { + return request("/api/config").then(function (data) { + state.files = normalizeArray(data && data.files).map(normalizeHookFile); + state.readOnly = !!(data && data.readOnly); + state.readOnlyReason = (data && data.readOnlyReason) || ""; + + if (!state.files.length) { + state.currentFile = ""; + state.currentHookId = ""; + } else { + var fileExists = false; + state.files.forEach(function (file) { + if (file.path === state.currentFile) { + fileExists = true; + } + }); + + if (!fileExists) { + state.currentFile = state.files[0].path; + } + + var currentFile = getCurrentFile(); + var hooks = currentFile ? normalizeArray(currentFile.hooks) : []; + var desiredHookId = preferredHookId || state.currentHookId; + var hookExists = false; + + hooks.forEach(function (hookItem) { + if (hookItem.id === desiredHookId) { + hookExists = true; + } + }); + + if (hookExists) { + state.currentHookId = desiredHookId; + } else { + state.currentHookId = hooks.length ? hooks[0].id : ""; + } + } + + showWorkspace(); + renderAll(); + setStatus(els.workspaceStatus, "配置已同步。", "success"); + loadUpdateStatus().catch(function () { + renderUpdateStatus(null); + }); + }); + } + + function renderUpdateStatus(data) { + var status = data || {}; + els.currentVersion.textContent = status.currentVersion || "-"; + els.latestVersion.textContent = status.latestVersion || "尚未检查"; + els.checkUpdateBtn.disabled = status.enabled === false; + + if (status.error) { + setStatus(els.updateStatus, status.error, "error"); + } else if (status.available) { + setStatus(els.updateStatus, "发现新版本 " + status.latestVersion + "。请使用 CLI 完成更新。", "success"); + } else if (status.checkedAt) { + setStatus(els.updateStatus, "当前已是最新版本。", "success"); + } else if (status.enabled === false) { + setStatus(els.updateStatus, "更新检查已禁用。", "muted"); + } else { + setStatus(els.updateStatus, "尚未检查更新。", "muted"); + } + } + + function loadUpdateStatus() { + return request("/api/update/status").then(function (data) { + renderUpdateStatus(data); + }); + } + + function checkForUpdate() { + els.checkUpdateBtn.disabled = true; + setStatus(els.updateStatus, "正在检查更新...", "muted"); + return request("/api/update/check", { method: "POST" }).then(function (data) { + renderUpdateStatus(data); + }).catch(function (error) { + setStatus(els.updateStatus, error.message || "检查更新失败。", "error"); + loadUpdateStatus().catch(function () {}); + }).finally(function () { + els.checkUpdateBtn.disabled = false; + }); + } + + function handleSave() { + if (!state.currentFile) { + setStatus(els.workspaceStatus, "当前没有可写入的 hooks 文件。", "error"); + return; + } + + var result = buildHookFromForm(); + if (result.errors.length) { + setStatus(els.workspaceStatus, result.errors[0], "error"); + return; + } + + var isUpdate = !!state.currentHookId; + var requestPath = "/api/hooks"; + var method = isUpdate ? "PUT" : "POST"; + var payload = { + file: state.currentFile, + hook: result.hook + }; + + if (isUpdate) { + payload.currentId = state.currentHookId; + } + + request(requestPath, { + method: method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }).then(function () { + state.currentHookId = result.hook.id || ""; + return loadConfig(state.currentHookId); + }).then(function () { + setStatus(els.workspaceStatus, "保存成功。", "success"); + }).catch(function (error) { + setStatus(els.workspaceStatus, error.message || "保存失败。", "error"); + }); + } + + function handleDelete() { + if (!state.currentHookId) { + setStatus(els.workspaceStatus, "当前没有选中的 hook。", "error"); + return; + } + + if (!window.confirm("确认删除 hook “" + state.currentHookId + "” 吗?")) { + return; + } + + request("/api/hooks", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file: state.currentFile, + currentId: state.currentHookId + }) + }).then(function () { + state.currentHookId = ""; + return loadConfig(); + }).then(function () { + setStatus(els.workspaceStatus, "删除成功。", "success"); + }).catch(function (error) { + setStatus(els.workspaceStatus, error.message || "删除失败。", "error"); + }); + } + + function bindBaseFormEvents() { + [ + els.hookId, + els.executeCommand, + els.commandWorkingDirectory, + els.commandTimeout, + els.httpMethods, + els.responseMessage, + els.incomingPayloadContentType, + els.successHttpResponseCode, + els.triggerRuleMismatchHttpResponseCode, + els.maxConcurrency + ].forEach(function (input) { + input.addEventListener("input", updatePreview); + }); + + [ + els.captureCommandOutput, + els.captureCommandOutputOnError, + els.triggerSignatureSoftFailures, + els.keepFileEnvironment + ].forEach(function (checkbox) { + checkbox.addEventListener("change", updatePreview); + }); + + Object.keys(collectionDefs).forEach(function (key) { + var def = collectionDefs[key]; + def.addButton.addEventListener("click", function () { + appendCollectionRow(def, {}); + updatePreview(); + }); + }); + } + + els.loginForm.addEventListener("submit", function (event) { + event.preventDefault(); + + request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: els.totpCode.value }) + }).then(function () { + els.totpCode.value = ""; + return loadConfig(); + }).catch(function (error) { + setStatus(els.loginStatus, error.message || "登录失败。", "error"); + }); + }); + + els.fileSelect.addEventListener("change", function () { + state.currentFile = els.fileSelect.value; + var currentFile = getCurrentFile(); + var hooks = currentFile ? normalizeArray(currentFile.hooks) : []; + state.currentHookId = hooks.length ? hooks[0].id : ""; + renderAll(); + setStatus(els.workspaceStatus, "已切换文件。", "muted"); + }); + + els.refreshBtn.addEventListener("click", function () { + loadConfig(state.currentHookId).catch(function (error) { + setStatus(els.workspaceStatus, error.message || "刷新失败。", "error"); + }); + }); + + els.checkUpdateBtn.addEventListener("click", checkForUpdate); + + els.newHookBtn.addEventListener("click", function () { + state.currentHookId = ""; + renderHookList(); + renderCurrentHook(); + applyReadOnlyState(); + setStatus(els.workspaceStatus, "正在创建新 hook。", "muted"); + }); + + els.saveBtn.addEventListener("click", handleSave); + els.deleteBtn.addEventListener("click", handleDelete); + + els.logoutBtn.addEventListener("click", function () { + request("/api/auth/logout", { method: "POST" }).finally(function () { + showLogin("已退出管理员会话。"); + }); + }); + + bindBaseFormEvents(); + + loadConfig().catch(function () { + showLogin("请输入 TOTP 动态码。"); + }); + +})(); diff --git a/adminui/assets/style.css b/adminui/assets/style.css new file mode 100644 index 00000000..64196063 --- /dev/null +++ b/adminui/assets/style.css @@ -0,0 +1,638 @@ +:root { + --bg: #f4efe7; + --bg-strong: #fffaf3; + --panel: rgba(255, 252, 247, 0.9); + --ink: #1f2a24; + --muted: #607067; + --line: rgba(31, 42, 36, 0.12); + --line-strong: rgba(31, 42, 36, 0.18); + --accent: #0f6d5a; + --accent-strong: #0a4f42; + --danger: #b0453c; + --danger-bg: rgba(176, 69, 60, 0.1); + --warn-bg: rgba(183, 123, 32, 0.12); + --muted-bg: rgba(31, 42, 36, 0.05); + --shadow: 0 24px 64px rgba(31, 42, 36, 0.12); + --radius: 22px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(15, 109, 90, 0.16), transparent 26rem), + radial-gradient(circle at bottom right, rgba(176, 69, 60, 0.1), transparent 24rem), + linear-gradient(180deg, #faf5ee 0%, #efe6db 100%); + font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif; +} + +.shell { + width: min(1440px, calc(100vw - 32px)); + margin: 24px auto 40px; +} + +.hero { + padding: 28px 30px; + border: 1px solid var(--line); + border-radius: calc(var(--radius) + 4px); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(247, 241, 233, 0.84)); + box-shadow: var(--shadow); +} + +.eyebrow { + display: inline-block; + padding: 6px 10px; + border-radius: 999px; + background: rgba(15, 109, 90, 0.1); + color: var(--accent-strong); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1 { + margin: 16px 0 10px; + font-size: clamp(30px, 5vw, 48px); + line-height: 1.02; + letter-spacing: -0.04em; +} + +.hero p, +.panel-subtitle, +.section-head p, +.tiny, +.meta-card, +.status, +.banner, +.preview pre, +.hook-item small { + line-height: 1.55; +} + +.hero p { + margin: 0; + max-width: 860px; + color: var(--muted); + font-size: 15px; +} + +.workspace { + display: grid; + grid-template-columns: 320px minmax(0, 1fr); + gap: 20px; + margin-top: 20px; +} + +.workspace[hidden], +.login[hidden] { + display: none; +} + +.panel { + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--shadow); + backdrop-filter: blur(14px); +} + +.panel-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px 0; +} + +.panel-head h2, +.panel-head h3, +.toolbar-row h3, +.toolbar-row h4, +.section-head h3 { + margin: 0; + letter-spacing: -0.02em; +} + +.panel-head h2 { + font-size: 18px; +} + +.panel-subtitle { + margin: 6px 0 0; + font-size: 13px; + color: var(--muted); +} + +.panel-body { + padding: 18px 20px 20px; +} + +.login { + max-width: 420px; + margin: 28px auto 0; +} + +.stack { + display: grid; + gap: 14px; +} + +label { + display: block; + margin-bottom: 8px; + font-size: 12px; + font-weight: 700; + color: var(--muted); + letter-spacing: 0.03em; + text-transform: uppercase; +} + +input, +select, +button { + font: inherit; +} + +input, +select { + width: 100%; + height: 44px; + padding: 0 14px; + border: 1px solid rgba(31, 42, 36, 0.18); + border-radius: 14px; + background: rgba(255, 255, 255, 0.96); + color: var(--ink); + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +input:focus, +select:focus { + outline: none; + border-color: rgba(15, 109, 90, 0.55); + box-shadow: 0 0 0 4px rgba(15, 109, 90, 0.12); + transform: translateY(-1px); +} + +code, +pre, +.hook-item strong, +.code-input { + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; +} + +.code-input { + letter-spacing: 0.35em; + text-align: center; + font-size: 22px; +} + +button { + border: 0; + border-radius: 14px; + min-height: 42px; + padding: 0 16px; + cursor: pointer; + transition: transform 150ms ease, opacity 150ms ease, box-shadow 150ms ease; +} + +button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(31, 42, 36, 0.12); +} + +button:disabled { + opacity: 0.48; + cursor: not-allowed; + box-shadow: none; +} + +.primary { + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + color: #fff; +} + +.secondary { + background: rgba(31, 42, 36, 0.08); + color: var(--ink); +} + +.ghost { + background: transparent; + color: var(--accent-strong); + border: 1px dashed rgba(15, 109, 90, 0.28); +} + +.danger { + background: rgba(176, 69, 60, 0.14); + color: var(--danger); +} + +.meta-card, +.status, +.banner { + padding: 14px 16px; + border-radius: 16px; + font-size: 13px; +} + +.meta-card { + background: rgba(15, 109, 90, 0.08); + color: var(--accent-strong); +} + +.update-panel { + padding-top: 4px; + border-top: 1px solid var(--line); +} + +.version-list { + display: grid; + gap: 8px; + margin: 12px 0 0; +} + +.version-list > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.version-list dt { + color: var(--muted); + font-size: 12px; +} + +.version-list dd { + margin: 0; + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + font-size: 13px; + overflow-wrap: anywhere; +} + +.status { + margin-top: 14px; + border: 1px solid rgba(31, 42, 36, 0.08); +} + +.status-muted { + background: var(--muted-bg); + color: var(--muted); +} + +.status-error { + background: var(--danger-bg); + border-color: rgba(176, 69, 60, 0.18); + color: #7b3029; +} + +.status-success { + background: rgba(15, 109, 90, 0.1); + border-color: rgba(15, 109, 90, 0.18); + color: var(--accent-strong); +} + +.banner { + margin-bottom: 16px; + background: var(--warn-bg); + color: #81561d; + border: 1px solid rgba(183, 123, 32, 0.18); +} + +.toolbar-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.toolbar-row h3, +.toolbar-row h4 { + font-size: 15px; +} + +.hook-list { + display: grid; + gap: 10px; + max-height: 58vh; + overflow: auto; + padding-right: 2px; +} + +.hook-item { + width: 100%; + padding: 14px; + border: 1px solid rgba(31, 42, 36, 0.1); + border-radius: 16px; + background: rgba(255, 255, 255, 0.88); + cursor: pointer; + text-align: left; +} + +.hook-item.active { + border-color: rgba(15, 109, 90, 0.42); + background: rgba(15, 109, 90, 0.1); +} + +.hook-item strong { + display: block; + font-size: 13px; + line-height: 1.4; +} + +.hook-item small { + display: block; + margin-top: 6px; + color: var(--muted); + font-size: 12px; +} + +.hook-form { + display: grid; + gap: 16px; +} + +.editor-fieldset { + margin: 0; + padding: 0; + border: 0; + min-width: 0; +} + +.section, +.preview { + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.55); +} + +.section { + padding: 18px; +} + +.section-head { + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.section-head h3 { + font-size: 16px; +} + +.section-head p { + margin: 0; + color: var(--muted); + font-size: 13px; + max-width: 560px; +} + +.grid { + display: grid; + gap: 14px; +} + +.grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.grid-span-2 { + grid-column: span 2; +} + +.toggle-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; +} + +.toggle { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(255, 255, 255, 0.7); + cursor: pointer; +} + +.toggle input { + width: 18px; + height: 18px; + margin: 0; + padding: 0; + accent-color: var(--accent); +} + +.toggle span { + font-size: 14px; + line-height: 1.45; +} + +.subsection + .subsection { + margin-top: 18px; +} + +.repeatable-list { + display: grid; + gap: 10px; +} + +.repeatable-empty { + padding: 14px 16px; + border: 1px dashed rgba(31, 42, 36, 0.18); + border-radius: 16px; + color: var(--muted); + font-size: 13px; +} + +.repeatable-row { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(255, 255, 255, 0.8); +} + +.repeatable-row.columns-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)) 44px; +} + +.repeatable-row.columns-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)) 44px; +} + +.repeatable-row.columns-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)) 44px; +} + +.repeatable-row.columns-5 { + grid-template-columns: repeat(5, minmax(0, 1fr)) 44px; +} + +.field-inline-label { + display: block; + margin-bottom: 6px; + font-size: 11px; + font-weight: 700; + color: var(--muted); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.row-check { + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(255, 255, 255, 0.92); +} + +.row-check input { + width: 18px; + height: 18px; + margin: 0; + padding: 0; + accent-color: var(--accent); +} + +.icon-button { + width: 44px; + min-height: 44px; + padding: 0; + border-radius: 14px; + background: rgba(176, 69, 60, 0.12); + color: var(--danger); +} + +.rule-editor { + display: grid; + gap: 12px; +} + +.rule-card { + border: 1px solid var(--line-strong); + border-radius: 18px; + background: rgba(255, 255, 255, 0.82); + padding: 14px; +} + +.rule-card[data-depth="1"] { + margin-left: 14px; +} + +.rule-card[data-depth="2"] { + margin-left: 28px; +} + +.rule-card[data-depth="3"] { + margin-left: 42px; +} + +.rule-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.rule-head strong { + font-size: 14px; +} + +.rule-children { + display: grid; + gap: 12px; + margin-top: 14px; +} + +.rule-note { + padding: 12px 14px; + border-radius: 14px; + background: var(--muted-bg); + color: var(--muted); + font-size: 13px; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 16px; +} + +.preview { + margin-top: 16px; + overflow: hidden; +} + +.preview summary { + padding: 14px 16px; + cursor: pointer; + font-weight: 700; +} + +.preview pre { + margin: 0; + padding: 0 16px 16px; + color: var(--ink); + overflow: auto; + font-size: 12px; +} + +.tiny { + margin-top: 10px; + color: var(--muted); + font-size: 12px; +} + +@media (max-width: 1180px) { + .workspace { + grid-template-columns: 1fr; + } +} + +@media (max-width: 920px) { + .grid-2, + .grid-3, + .toggle-grid, + .repeatable-row.columns-2, + .repeatable-row.columns-3, + .repeatable-row.columns-4, + .repeatable-row.columns-5 { + grid-template-columns: 1fr; + } + + .grid-span-2 { + grid-column: auto; + } + + .repeatable-row.columns-2 .icon-button, + .repeatable-row.columns-3 .icon-button, + .repeatable-row.columns-4 .icon-button, + .repeatable-row.columns-5 .icon-button { + width: 100%; + } + + .rule-card[data-depth="1"], + .rule-card[data-depth="2"], + .rule-card[data-depth="3"] { + margin-left: 0; + } +} diff --git a/adminui/index.html b/adminui/index.html new file mode 100644 index 00000000..c7461414 --- /dev/null +++ b/adminui/index.html @@ -0,0 +1,240 @@ + + + + + + Webhook Admin + + + + + +
+
+ Webhook Control Plane +

集中管理所有 webhook 配置

+

使用 TOTP 获取短期 JWT 会话。页面直接管理已加载的 hooks 文件,不需要额外前端构建流程。

+
+ + + + +
+ + diff --git a/config.go b/config.go new file mode 100644 index 00000000..cf78a055 --- /dev/null +++ b/config.go @@ -0,0 +1,118 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v2" +) + +// configFile holds the path to the YAML configuration file. +// It is extracted from os.Args before flag.Parse() runs. +var configFile string + +// loadConfigFile reads a YAML configuration file and sets the corresponding +// flags. Flags set via the command line take precedence over the config file +// because this function is called before flag.Parse(). +// +// Supported YAML structure: +// +// ip: 0.0.0.0 +// port: 9000 +// nopanic: true +// hooks: +// - /etc/webhook/hooks.yaml +// header: +// - "X-Custom=value" +func loadConfigFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read config file %q: %w", path, err) + } + + // Use a generic map so we can handle both scalar and list values. + var cfg map[string]interface{} + if err := yaml.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("failed to parse config file %q: %w", path, err) + } + + for key, val := range cfg { + f := flag.CommandLine.Lookup(key) + if f == nil { + return fmt.Errorf("unknown option %q in config file %q", key, path) + } + + switch v := val.(type) { + case []interface{}: + // List values: call flag.Set for each element (supports -hooks, -header, etc.) + for _, item := range v { + s := fmt.Sprintf("%v", item) + if err := flag.CommandLine.Set(key, s); err != nil { + return fmt.Errorf("error setting flag %q to %q: %w", key, s, err) + } + } + case bool: + s := "false" + if v { + s = "true" + } + if err := flag.CommandLine.Set(key, s); err != nil { + return fmt.Errorf("error setting flag %q to %q: %w", key, s, err) + } + default: + s := fmt.Sprintf("%v", v) + if err := flag.CommandLine.Set(key, s); err != nil { + return fmt.Errorf("error setting flag %q to %q: %w", key, s, err) + } + } + } + + return nil +} + +// extractConfigFlag scans os.Args for -config / -c / --config and returns the +// path value. It also removes those arguments from os.Args so that flag.Parse() +// does not see them (since -config is not a registered flag). +func extractConfigFlag() string { + var path string + var newArgs []string + + args := os.Args[1:] // skip program name + newArgs = append(newArgs, os.Args[0]) + + for i := 0; i < len(args); i++ { + arg := args[i] + + var matched bool + var value string + + switch { + case arg == "-c" || arg == "-config" || arg == "--config": + matched = true + if i+1 < len(args) { + i++ + value = args[i] + } + case strings.HasPrefix(arg, "-c="): + matched = true + value = strings.TrimPrefix(arg, "-c=") + case strings.HasPrefix(arg, "-config="): + matched = true + value = strings.TrimPrefix(arg, "-config=") + case strings.HasPrefix(arg, "--config="): + matched = true + value = strings.TrimPrefix(arg, "--config=") + } + + if matched { + path = value + } else { + newArgs = append(newArgs, arg) + } + } + + os.Args = newArgs + return path +} diff --git a/docs/Hook-Definition.md b/docs/Hook-Definition.md index 8a7e7443..b6dd394c 100644 --- a/docs/Hook-Definition.md +++ b/docs/Hook-Definition.md @@ -7,6 +7,8 @@ Hooks are defined as objects in the JSON or YAML hooks configuration file. Pleas * `id` - specifies the ID of your hook. This value is used to create the HTTP endpoint (http://yourserver:port/hooks/your-hook-id) * `execute-command` - specifies the command that should be executed when the hook is triggered * `command-working-directory` - specifies the working directory that will be used for the script when it's executed + * `command-timeout` - overrides the default command execution timeout for this hook. Accepts a Go duration string such as `30s`, `2m`, or `1h30m`. An empty value inherits the global `-command-timeout` flag. A value of `0` disables the timeout for this hook. + * `max-concurrency` - overrides the default maximum number of concurrent executions for this hook. An empty value inherits the global `-max-concurrency` flag. A value of `0` disables the limit for this hook. * `response-message` - specifies the string that will be returned to the hook initiator * `response-headers` - specifies the list of headers in format `{"name": "X-Example-Header", "value": "it works"}` that will be returned in HTTP response for the hook * `success-http-response-code` - specifies the HTTP status code to be returned upon success @@ -23,6 +25,7 @@ Hooks are defined as objects in the JSON or YAML hooks configuration file. Pleas * `trigger-rule` - specifies the rule that will be evaluated in order to determine should the hook be triggered. Check [Hook rules page](Hook-Rules.md) to see the list of valid rules and their usage * `trigger-rule-mismatch-http-response-code` - specifies the HTTP status code to be returned when the trigger rule is not satisfied * `trigger-signature-soft-failures` - allow signature validation failures within Or rules; by default, signature failures are treated as errors. +* `keep-file-environment` - expose uploaded multipart files to the executed command as temporary environment variables. For a multipart form field named `pkg`, webhook will provide `HOOK_FILE_PKG` with the temporary file path and `HOOK_FILENAME_PKG` with the original filename. These files exist only for the lifetime of the command execution and are removed afterwards. Multipart field names are uppercased and embedded into the environment variable name verbatim; if you plan to read them from a shell script, prefer field names that are safe shell variable suffixes such as letters, numbers, and underscores. ## Examples Check out [Hook examples page](Hook-Examples.md) for more complex examples of hooks. diff --git a/docs/Hook-Examples.md b/docs/Hook-Examples.md index 66aa5a72..c44041d5 100644 --- a/docs/Hook-Examples.md +++ b/docs/Hook-Examples.md @@ -657,9 +657,11 @@ Content-Disposition: form-data; name="payload" Content-Disposition: form-data; name="thumb"; filename="thumb.jpg" ``` -We key off of the `name` attribute in the `Content-Disposition` value. - -## Pass string arguments to command +We key off of the `name` attribute in the `Content-Disposition` value. + +If you need the executed command to read uploaded files directly, enable `keep-file-environment`. For a file part named `pkg`, webhook will expose `HOOK_FILE_PKG` with the temporary file path and `HOOK_FILENAME_PKG` with the original filename while the command is running, then clean the file up when the command exits. Since the multipart field name is copied into the environment variable name after uppercasing, prefer shell-safe field names such as `pkg`, `release_tarball`, or `artifact1`. + +## Pass string arguments to command To pass simple string arguments to a command, use the `string` parameter source. The following example will pass two static string parameters ("-e 123123") to the diff --git a/docs/Webhook-Parameters.md b/docs/Webhook-Parameters.md index 31e6d61b..73b77cc9 100644 --- a/docs/Webhook-Parameters.md +++ b/docs/Webhook-Parameters.md @@ -1,16 +1,24 @@ # Webhook parameters ``` Usage of webhook: + -access-blacklist string + comma-separated list of client IPs or CIDRs denied from accessing the service + -access-whitelist string + comma-separated list of client IPs or CIDRs allowed to access the service -cert string path to the HTTPS certificate pem file (default "cert.pem") -cipher-suites string comma-separated list of supported TLS cipher suites + -command-timeout string + default timeout for hook command execution (for example 30s); 0 disables the timeout -debug show debug output -header value response header to return, specified in format name=value, use multiple times to set multiple headers -hooks value path to the json file containing defined hooks the webhook should serve, use multiple times to load from different files + -hooks-dir value + directory containing JSON or YAML hooks files; use multiple times to load and watch multiple directories -hotreload watch hooks file for changes and reload them automatically -http-methods string @@ -23,6 +31,10 @@ Usage of webhook: list available TLS cipher suites -logfile string send log output to a file; implicitly enables verbose logging + -max-concurrency int + default maximum number of concurrent executions per hook; 0 disables the limit + -max-body-size int + maximum webhook request body size in bytes (0 disables the limit) (default 10485760) -max-multipart-mem int maximum memory in bytes for parsing multipart form data before disk caching (default 1048576) -nopanic @@ -39,6 +51,8 @@ Usage of webhook: set user ID after opening listening port; must be used with setgid -socket string path to a Unix socket (e.g. /tmp/webhook.sock) or Windows named pipe (e.g. \\.\pipe\webhook) to use instead of listening on an ip and port; if specified, the ip and port options are ignored + -status-path string + URL path for the service status endpoint (default "status") -template parse hooks file as a Go template -tls-min-version string @@ -49,6 +63,12 @@ Usage of webhook: show verbose output -version display webhook version and quit + -update-enabled + enable update checks in the admin API (default true) + -update-repository string + GitHub repository used for updates (default "xtulnx/webhook") + -update-state-dir string + directory for update state; defaults to the current working directory -x-request-id use X-Request-Id header, if present, as request ID -x-request-id-limit int @@ -57,6 +77,14 @@ Usage of webhook: Use any of the above specified flags to override their default behavior. +`-command-timeout` and `-max-concurrency` act as defaults for all hooks. Individual hooks can override them using the `command-timeout` and `max-concurrency` hook properties. Within a hook definition, `0` explicitly disables the inherited limit. + +## Access control and request limits + +`-access-whitelist` and `-access-blacklist` apply to every HTTP endpoint, including hooks, status, and the admin API. Values may be IPv4/IPv6 addresses or CIDR ranges, separated by commas. Configure only one; when a whitelist is configured, all other addresses are denied. IP matching uses the resolved client address, and only a request from a `-trusted-proxies` address may supply `-real-ip-header`. + +`-max-body-size` limits non-multipart and multipart request bodies before parsing. It defaults to 10 MiB; set it to `0` only when an unlimited body is explicitly required. + # Live reloading hooks If you are running an OS that supports the HUP or USR1 signal, you can use it to trigger hooks reload from hooks file, without restarting the webhook instance. ```bash @@ -64,3 +92,9 @@ kill -USR1 webhookpid kill -HUP webhookpid ``` + +`-hooks-dir` loads all direct child files ending in `.json`, `.yaml`, or `.yml` and automatically watches the directory, even when `-hotreload` is not set. New and removed files are reflected at runtime. A malformed update or a duplicate hook ID is rejected while the last valid configuration remains active. Other file types, subdirectories, and symbolic links that resolve outside the configured directory are ignored. Existing `-hooks` files continue to require `-hotreload` for automatic reloads. + +# Service status + +`GET /status` returns service uptime, the current hook and source-file counts, hook IDs, the most recent successful load time, and watcher state. Use `-status-path` to move the endpoint. The response intentionally omits absolute paths, command definitions, environment mappings, and load error details. diff --git a/docs/examples/oss-release/.env.update-web.example b/docs/examples/oss-release/.env.update-web.example new file mode 100644 index 00000000..238fc42b --- /dev/null +++ b/docs/examples/oss-release/.env.update-web.example @@ -0,0 +1,23 @@ +# OSS 发布示例配置。 +# 使用时复制为 .env.update-web,并填写真实值;不要提交真实密钥或校验码。 + +# 必填:OSS 基础路径,不要包含 APP 后缀。 +WEB_DEV_OSS_BASE_URI='oss://your-bucket/path/to/web-root' + +# 必填:公网访问基础地址。 +WEB_DEV_ACCESS_BASE_URL='https://static.example.com' + +# 可选:如果 ossutil 不在 PATH 中,可以改成实际二进制路径。 +OSSUTIL_BIN='ossutil64' + +# 可选:超级码。设置后可发布任意合法 APP 名称。 +WEB_DEV_SUPER_CODE='' + +# 必填:允许发布的 APP 清单,多个 APP 用空格分隔。 +WEB_DEV_ALLOWED_APPS='iot2 admin-web' + +# 必填:每个 APP 的校验码。 +# 变量名格式为 WEB_DEV_CODE_${APP},其中 APP 转为大写,- 转为 _。 +# 例如 APP=admin-web 时,对应变量名为 WEB_DEV_CODE_ADMIN_WEB。 +WEB_DEV_CODE_IOT2='CHANGE_ME_IOT2_CODE' +WEB_DEV_CODE_ADMIN_WEB='CHANGE_ME_ADMIN_WEB_CODE' diff --git a/docs/examples/oss-release/hooks.yaml b/docs/examples/oss-release/hooks.yaml new file mode 100644 index 00000000..e84e9578 --- /dev/null +++ b/docs/examples/oss-release/hooks.yaml @@ -0,0 +1,33 @@ +# 上传 Web 资源并发布到 OSS 的 webhook 示例。 +# 生产使用前,请把占位符替换为实际值。 +- id: dev/web + # 必填:替换为服务器上 update.web-dev.sh 的绝对路径。 + execute-command: /path/to/docs/examples/oss-release/update.web-dev.sh + # 必填:替换为服务器上的示例目录路径,.env.update-web 和临时缓存会放在这里。 + command-working-directory: /path/to/docs/examples/oss-release + http-methods: + - POST + + keep-file-environment: true + + pass-environment-to-command: + - source: payload + name: app + envname: APP + - source: payload + name: code + envname: CODE + + trigger-rule: + and: + - match: + type: value + # 必填:替换为实际 query token。 + value: CHANGE_ME_QUERY_TOKEN + parameter: + source: url + name: token + - match: + type: ip-whitelist + # 必填:替换为实际允许访问的 CIDR 网段。 + ip-range: 192.0.2.10/32 diff --git a/docs/examples/oss-release/update.web-dev.sh b/docs/examples/oss-release/update.web-dev.sh new file mode 100755 index 00000000..7ed92b13 --- /dev/null +++ b/docs/examples/oss-release/update.web-dev.sh @@ -0,0 +1,146 @@ +#!/bin/bash + +set -o pipefail + +# 默认读取当前工作目录下的 .env.update-web。 +# 也可以通过 UPDATE_WEB_ENV_FILE 指定其他配置文件路径。 +UPDATE_WEB_ENV_FILE="${UPDATE_WEB_ENV_FILE:-.env.update-web}" +if [ -f "${UPDATE_WEB_ENV_FILE}" ]; then + case "${UPDATE_WEB_ENV_FILE}" in + */*) . "${UPDATE_WEB_ENV_FILE}" ;; + *) . "./${UPDATE_WEB_ENV_FILE}" ;; + esac +fi + +# ===== 发布配置默认值 ===== + +WEB_DEV_OSS_BASE_URI="${WEB_DEV_OSS_BASE_URI:-}" +WEB_DEV_ACCESS_BASE_URL="${WEB_DEV_ACCESS_BASE_URL:-}" +OSSUTIL_BIN="${OSSUTIL_BIN:-ossutil64}" +WEB_DEV_SUPER_CODE="${WEB_DEV_SUPER_CODE:-}" +WEB_DEV_ALLOWED_APPS="${WEB_DEV_ALLOWED_APPS:-}" + +tar_warning_options=() +if tar --help 2>/dev/null | grep -q -- "--warning"; then + tar_warning_options=(--warning=no-unknown-keyword) +fi + +fail() { + echo "$1" + exit 0 +} + +require_env() { + name="$1" + value="${!name:-}" + + [ -n "${value}" ] || fail "缺少环境变量 ${name},请填写实际值" +} + +safe_name() { + [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$ ]] +} + +project_code_for() { + app="$1" + + for allowed_app in ${WEB_DEV_ALLOWED_APPS}; do + if [ "${allowed_app}" = "${app}" ]; then + code_var="WEB_DEV_CODE_$(echo "${app}" | tr '[:lower:]-' '[:upper:]_')" + echo "${!code_var:-}" + return 0 + fi + done + + return 1 +} + +validate_identity() { + [ -z "${APP:-}" ] && fail "缺少 APP" + [ -z "${CODE:-}" ] && fail "缺少 CODE" + + safe_name "${APP}" || fail "APP 不合法,只允许字母、数字、下划线和中划线,且不能以符号开头" + + if [ -n "${WEB_DEV_SUPER_CODE:-}" ] && [ "${CODE}" = "${WEB_DEV_SUPER_CODE}" ]; then + return 0 + fi + + expected_code="$(project_code_for "${APP}")" || fail "APP 不在允许清单中" + [ -z "${expected_code}" ] && fail "项目校验码未配置,请联系管理员" + [ "${CODE}" = "${expected_code}" ] || fail "CODE 校验失败" +} + +validate_tar_entries() { + manifest_file="$1" + + awk ' + $0 ~ /^\/|(^|\/)\.\.(\/|$)/ { + bad = 1 + } + END { + exit bad + } + ' "${manifest_file}" +} + +pkg_file="${HOOK_FILE_PKG:-${HOOK_FILE_ATTACHMENT:-}}" +[ -z "${pkg_file}" ] && fail "NOT FOUND" +[ ! -f "${pkg_file}" ] && fail "NOT FOUND" + +validate_identity + +require_env WEB_DEV_OSS_BASE_URI +require_env WEB_DEV_ACCESS_BASE_URL + +cache_dir="./.cache-web-dev-${APP}" +manifest="./.cache-web-dev-${APP}.manifest" + +rm -rf -- "${cache_dir}" +rm -f -- "${manifest}" +mkdir -p "${cache_dir}" || fail "缓存目录创建失败" + +tar "${tar_warning_options[@]}" -tzf "${pkg_file}" > "${manifest}" 2>/dev/null +if [ $? -ne 0 ]; then + fail "资源格式不支持,请联系管理员" +fi + +validate_tar_entries "${manifest}" || fail "资源包包含非法路径,请联系管理员" +[ ! -s "${manifest}" ] && fail "资源包为空,请联系管理员" + +WEB_APP="dev-${APP}" +oss_target="${WEB_DEV_OSS_BASE_URI%/}/${WEB_APP}/" +access_url="${WEB_DEV_ACCESS_BASE_URL%/}/${WEB_APP}/" + +update() { + oss_output="$("${OSSUTIL_BIN}" sync "${cache_dir}" "${oss_target}" --delete -f 2>&1)" + if [ $? -ne 0 ]; then + echo "OSS 同步失败" + echo "${oss_output}" + return 1 + fi + + echo "资源同步完成" + echo "对象储存地址: ${oss_target}" + echo "访问地址: ${access_url}" +} + +tar "${tar_warning_options[@]}" -xzf "${pkg_file}" -C "${cache_dir}" --no-same-owner --no-same-permissions 2>/dev/null +if [ $? -ne 0 ]; then + fail "资源解压失败" +fi + +find "${cache_dir}" -type d -name "__MACOSX" -prune -exec rm -rf {} + +find "${cache_dir}" -type f \( -name ".DS_Store" -o -name "._*" \) -delete + +if find "${cache_dir}" -type l -print -quit | grep -q .; then + fail "资源包包含符号链接,请联系管理员" +fi + +if ! find "${cache_dir}" -mindepth 1 -print -quit | grep -q .; then + fail "资源包为空,请联系管理员" +fi + +update && echo "更新成功" && exit 0 + +echo "更新失败" +exit 0 diff --git a/execution.go b/execution.go new file mode 100644 index 00000000..ccedd0be --- /dev/null +++ b/execution.go @@ -0,0 +1,146 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/adnanh/webhook/internal/hook" +) + +var ( + commandTimeout = flag.String("command-timeout", "", "default timeout for hook command execution (for example 30s); 0 disables the timeout") + maxConcurrency = flag.Int("max-concurrency", 0, "default maximum number of concurrent executions per hook; 0 disables the limit") + defaultExecTimeout time.Duration + + errHookConcurrencyLimit = errors.New("hook concurrency limit reached") +) + +type hookExecutionLimiter struct { + limit int + tokens chan struct{} +} + +var hookExecutionLimiters = struct { + mu sync.Mutex + byHook map[string]*hookExecutionLimiter +}{ + byHook: make(map[string]*hookExecutionLimiter), +} + +func initExecutionSettings() error { + value := strings.TrimSpace(*commandTimeout) + switch value { + case "", "0": + defaultExecTimeout = 0 + default: + timeout, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("invalid command-timeout: %w", err) + } + if timeout < 0 { + return errors.New("command-timeout must be zero or greater") + } + defaultExecTimeout = timeout + } + + if *maxConcurrency < 0 { + return errors.New("max-concurrency must be zero or greater") + } + + return nil +} + +func handleHook(h *hook.Hook, r *hook.Request) (string, error) { + release, err := acquireHookExecutionSlot(h) + if err != nil { + return "", err + } + + return executeHook(h, r, true, release) +} + +func handleHookAsync(h *hook.Hook, r *hook.Request) error { + release, err := acquireHookExecutionSlot(h) + if err != nil { + return err + } + + go func() { + _, _ = executeHook(h, r, false, release) + }() + + return nil +} + +func hookExecutionErrorStatus(err error) (int, string) { + if errors.Is(err, errHookConcurrencyLimit) { + return http.StatusServiceUnavailable, "Hook concurrency limit exceeded. Please try again later." + } + + return http.StatusInternalServerError, "Error occurred while executing the hook's command. Please check your logs for more details." +} + +func acquireHookExecutionSlot(h *hook.Hook) (func(), error) { + limit, err := h.EffectiveMaxConcurrency(*maxConcurrency) + if err != nil { + return nil, err + } + if limit == 0 { + return func() {}, nil + } + + limiter := executionLimiterForHook(h.ID, limit) + + select { + case limiter.tokens <- struct{}{}: + return func() { + <-limiter.tokens + }, nil + default: + return nil, fmt.Errorf("%w (%d running)", errHookConcurrencyLimit, limit) + } +} + +func executionLimiterForHook(id string, limit int) *hookExecutionLimiter { + hookExecutionLimiters.mu.Lock() + defer hookExecutionLimiters.mu.Unlock() + + limiter := hookExecutionLimiters.byHook[id] + if limiter == nil || limiter.limit != limit { + limiter = &hookExecutionLimiter{ + limit: limit, + tokens: make(chan struct{}, limit), + } + hookExecutionLimiters.byHook[id] = limiter + } + + return limiter +} + +func hookExecutionContext(h *hook.Hook, r *hook.Request, waitForCommand bool) (context.Context, context.CancelFunc, bool, error) { + timeout, err := h.EffectiveCommandTimeout(defaultExecTimeout) + if err != nil { + return nil, nil, false, err + } + + ctx := context.Background() + useContext := timeout > 0 + + if waitForCommand && r != nil && r.RawRequest != nil { + ctx = r.RawRequest.Context() + useContext = true + } + + if timeout > 0 { + ctx, cancel := context.WithTimeout(ctx, timeout) + return ctx, cancel, true, nil + } + + return ctx, func() {}, useContext, nil +} diff --git a/hook_sources.go b/hook_sources.go new file mode 100644 index 00000000..4eb179d8 --- /dev/null +++ b/hook_sources.go @@ -0,0 +1,426 @@ +package main + +import ( + "errors" + "fmt" + "log" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "time" + + "github.com/adnanh/webhook/internal/hook" + "github.com/fsnotify/fsnotify" +) + +type hookLoadMetadata struct { + LoadedAt time.Time +} + +type hookLoadFailure struct { + FailedAt time.Time + Err string +} + +type hookApplyError struct { + err error +} + +func (e *hookApplyError) Error() string { return e.err.Error() } +func (e *hookApplyError) Unwrap() error { return e.err } + +var ( + watcher *fsnotify.Watcher + + configuredHookFiles []string + explicitHookFiles = make(map[string]struct{}) + hookDirectoryByFile = make(map[string]string) + hookLoadMetadataByFile = make(map[string]hookLoadMetadata) + hookLoadFailures = make(map[string]hookLoadFailure) + lastHooksLoadTime time.Time +) + +func initializeHookSources() error { + explicit := append([]string(nil), hooksFiles...) + hooksFiles = nil + configuredHookFiles = nil + explicitHookFiles = make(map[string]struct{}) + hookDirectoryByFile = make(map[string]string) + + for _, path := range explicit { + normalized, err := normalizeSourcePath(path) + if err != nil { + return fmt.Errorf("invalid hooks file path %q: %w", path, err) + } + if _, exists := explicitHookFiles[normalized]; exists { + continue + } + explicitHookFiles[normalized] = struct{}{} + configuredHookFiles = append(configuredHookFiles, normalized) + } + + normalizedDirectories := make(hook.HooksFiles, 0, len(hooksDirectories)) + seenDirectories := make(map[string]struct{}, len(hooksDirectories)) + for _, directory := range hooksDirectories { + normalized, err := normalizeSourcePath(directory) + if err != nil { + return fmt.Errorf("invalid hooks directory path %q: %w", directory, err) + } + info, err := os.Stat(normalized) + if err != nil { + return fmt.Errorf("cannot access hooks directory %q: %w", directory, err) + } + if !info.IsDir() { + return fmt.Errorf("hooks directory %q is not a directory", directory) + } + if _, exists := seenDirectories[normalized]; exists { + continue + } + seenDirectories[normalized] = struct{}{} + normalizedDirectories = append(normalizedDirectories, normalized) + } + hooksDirectories = normalizedDirectories + + for _, path := range configuredHookFiles { + if err := reloadHooks(path); err != nil { + var applyErr *hookApplyError + if errors.As(err, &applyErr) { + return applyErr + } + } + } + for _, directory := range hooksDirectories { + if err := syncHooksDirectory(directory); err != nil { + return err + } + } + + return nil +} + +func normalizeSourcePath(path string) (string, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", errors.New("path must not be empty") + } + abs, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return "", err + } + return abs, nil +} + +func isHooksConfigFile(path string) bool { + switch strings.ToLower(filepath.Ext(path)) { + case ".json", ".yaml", ".yml": + return true + default: + return false + } +} + +func directoryHooksFiles(directory string) ([]string, error) { + entries, err := os.ReadDir(directory) + if err != nil { + return nil, fmt.Errorf("cannot read hooks directory %q: %w", directory, err) + } + + resolvedDirectory, err := filepath.EvalSymlinks(directory) + if err != nil { + return nil, fmt.Errorf("cannot resolve hooks directory %q: %w", directory, err) + } + + files := make([]string, 0, len(entries)) + for _, entry := range entries { + if !isHooksConfigFile(entry.Name()) { + continue + } + + path := filepath.Join(directory, entry.Name()) + resolvedPath, err := filepath.EvalSymlinks(path) + if err != nil { + log.Printf("ignoring hooks file %s: %v", path, err) + continue + } + relative, err := filepath.Rel(resolvedDirectory, resolvedPath) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) { + log.Printf("ignoring hooks file %s because it resolves outside its configured directory", path) + continue + } + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + log.Printf("ignoring hooks file %s because it is not a regular file", path) + continue + } + files = append(files, path) + } + + sort.Strings(files) + return files, nil +} + +func syncHooksDirectory(directory string) error { + files, err := directoryHooksFiles(directory) + if err != nil { + return err + } + + found := make(map[string]struct{}, len(files)) + for _, path := range files { + found[path] = struct{}{} + } + + loadedHooksMu.RLock() + removed := make([]string, 0) + for path, sourceDirectory := range hookDirectoryByFile { + if sourceDirectory == directory { + if _, exists := found[path]; !exists { + removed = append(removed, path) + } + } + } + loadedHooksMu.RUnlock() + for _, path := range removed { + removeHooks(path) + } + + parsed := make(map[string]hook.Hooks, len(files)) + parseFailures := make(map[string]error) + for _, path := range files { + hooksInFile := hook.Hooks{} + if err := hooksInFile.LoadFromFile(path, *asTemplate); err != nil { + parseFailures[path] = err + continue + } + parsed[path] = hooksInFile + } + + now := time.Now().UTC() + loadedHooksMu.Lock() + for _, path := range files { + hookDirectoryByFile[path] = directory + } + + candidate := cloneLoadedHooksMapLocked() + for path, hooksInFile := range parsed { + candidate[path] = cloneHooks(hooksInFile) + } + if err := validateUniqueHookIDs(candidate); err != nil { + for path, hooksInFile := range parsed { + if !reflect.DeepEqual(loadedHooksFromFiles[path], hooksInFile) { + hookLoadFailures[path] = hookLoadFailure{FailedAt: now, Err: err.Error()} + } + } + for path, parseErr := range parseFailures { + hookLoadFailures[path] = hookLoadFailure{FailedAt: now, Err: parseErr.Error()} + } + loadedHooksMu.Unlock() + log.Printf("couldn't apply hooks directory %s: %v; keeping the previous configuration", directory, err) + return nil + } + + for path, hooksInFile := range parsed { + loadedHooksFromFiles[path] = hooksInFile + if !containsString(hooksFiles, path) { + hooksFiles = append(hooksFiles, path) + } + markHookLoadedLocked(path, now) + } + for path, parseErr := range parseFailures { + hookLoadFailures[path] = hookLoadFailure{FailedAt: now, Err: parseErr.Error()} + } + loadedHooksMu.Unlock() + + for path, hooksInFile := range parsed { + log.Printf("loaded %d hook(s) from %s", len(hooksInFile), path) + } + for path, parseErr := range parseFailures { + log.Printf("couldn't load hooks from file %s: %v; keeping the previous configuration", path, parseErr) + } + + return nil +} + +func reloadHooks(path string) error { + log.Printf("attempting to load hooks from %s", path) + + info, err := os.Stat(path) + if err != nil { + recordHookLoadFailure(path, err) + log.Printf("couldn't load hooks from file! %v", err) + return err + } + if !info.Mode().IsRegular() { + err = errors.New("hooks source is not a regular file") + recordHookLoadFailure(path, err) + log.Printf("couldn't load hooks from file! %v", err) + return err + } + + hooksInFile := hook.Hooks{} + if err := hooksInFile.LoadFromFile(path, *asTemplate); err != nil { + recordHookLoadFailure(path, err) + log.Printf("couldn't load hooks from file! %v", err) + return err + } + + now := time.Now().UTC() + loadedHooksMu.Lock() + candidate := cloneLoadedHooksMapLocked() + candidate[path] = cloneHooks(hooksInFile) + if err := validateUniqueHookIDs(candidate); err != nil { + hookLoadFailures[path] = hookLoadFailure{FailedAt: now, Err: err.Error()} + loadedHooksMu.Unlock() + log.Printf("couldn't apply hooks from file %s: %v; keeping the previous configuration", path, err) + return &hookApplyError{err: err} + } + + loadedHooksFromFiles[path] = hooksInFile + if !containsString(hooksFiles, path) { + hooksFiles = append(hooksFiles, path) + } + markHookLoadedLocked(path, now) + loadedHooksMu.Unlock() + + log.Printf("found %d hook(s) in file", len(hooksInFile)) + for _, currentHook := range hooksInFile { + log.Printf("\tloaded: %s", currentHook.ID) + } + return nil +} + +func recordHookLoadFailure(path string, err error) { + loadedHooksMu.Lock() + hookLoadFailures[path] = hookLoadFailure{FailedAt: time.Now().UTC(), Err: err.Error()} + loadedHooksMu.Unlock() +} + +func markHookLoadedLocked(path string, loadedAt time.Time) { + loadedAt = loadedAt.UTC() + hookLoadMetadataByFile[path] = hookLoadMetadata{LoadedAt: loadedAt} + delete(hookLoadFailures, path) + lastHooksLoadTime = loadedAt +} + +func removeHooks(path string) { + loadedHooksMu.Lock() + removedCount := len(loadedHooksFromFiles[path]) + delete(loadedHooksFromFiles, path) + delete(hookLoadMetadataByFile, path) + delete(hookLoadFailures, path) + delete(hookDirectoryByFile, path) + + remaining := hooksFiles[:0] + for _, current := range hooksFiles { + if current != path { + remaining = append(remaining, current) + } + } + hooksFiles = remaining + loadedHooksMu.Unlock() + + if removedCount != 0 { + log.Printf("removed %d hook(s) loaded from %s", removedCount, path) + } +} + +func reloadAllHooks() { + for _, path := range configuredHookFiles { + _ = reloadHooks(path) + } + for _, directory := range hooksDirectories { + if err := syncHooksDirectory(directory); err != nil { + log.Printf("couldn't rescan hooks directory %s: %v", directory, err) + } + } +} + +func startHookWatcher() error { + var err error + watcher, err = fsnotify.NewWatcher() + if err != nil { + return err + } + + targets := make(map[string]struct{}) + if *hotReload { + for _, path := range configuredHookFiles { + targets[filepath.Dir(path)] = struct{}{} + } + } + for _, directory := range hooksDirectories { + targets[directory] = struct{}{} + } + + for target := range targets { + log.Printf("setting up hooks watcher for %s", target) + if err := watcher.Add(target); err != nil { + _ = watcher.Close() + watcher = nil + return fmt.Errorf("cannot watch %q: %w", target, err) + } + } + return nil +} + +func watchForFileChange() { + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + handleHookFileEvent(event) + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Println("watcher error:", err) + } + } +} + +func handleHookFileEvent(event fsnotify.Event) { + eventPath, err := normalizeSourcePath(event.Name) + if err != nil { + return + } + if event.Op&(fsnotify.Create|fsnotify.Write|fsnotify.Rename) != 0 { + // Editors and deployment tools often emit an event before an atomic + // replacement is fully visible through the directory entry. + time.Sleep(50 * time.Millisecond) + } + + for _, directory := range hooksDirectories { + if filepath.Dir(eventPath) == directory { + if err := syncHooksDirectory(directory); err != nil { + log.Printf("couldn't rescan hooks directory %s: %v", directory, err) + } + } + } + + if _, configured := explicitHookFiles[eventPath]; !configured || !*hotReload { + return + } + if event.Op&(fsnotify.Remove|fsnotify.Rename) != 0 { + if _, err := os.Stat(eventPath); os.IsNotExist(err) { + removeHooks(eventPath) + recordHookLoadFailure(eventPath, err) + return + } + } + if event.Op&(fsnotify.Create|fsnotify.Write|fsnotify.Rename) != 0 { + _ = reloadHooks(eventPath) + } +} + +func containsString(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} diff --git a/hook_sources_test.go b/hook_sources_test.go new file mode 100644 index 00000000..3b994c8a --- /dev/null +++ b/hook_sources_test.go @@ -0,0 +1,312 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/adnanh/webhook/internal/hook" +) + +func resetHookSourceTestState(t *testing.T) { + t.Helper() + + savedHooksFiles := append(hook.HooksFiles(nil), hooksFiles...) + savedDirectories := append(hook.HooksFiles(nil), hooksDirectories...) + savedConfigured := append([]string(nil), configuredHookFiles...) + savedExplicit := explicitHookFiles + savedDirectoryByFile := hookDirectoryByFile + savedMetadata := hookLoadMetadataByFile + savedFailures := hookLoadFailures + savedLastLoad := lastHooksLoadTime + savedLoaded := loadedHooksFromFiles + savedTemplate := *asTemplate + savedHotReload := *hotReload + savedWatcher := watcher + + hooksFiles = nil + hooksDirectories = nil + configuredHookFiles = nil + explicitHookFiles = make(map[string]struct{}) + hookDirectoryByFile = make(map[string]string) + hookLoadMetadataByFile = make(map[string]hookLoadMetadata) + hookLoadFailures = make(map[string]hookLoadFailure) + lastHooksLoadTime = time.Time{} + loadedHooksFromFiles = make(map[string]hook.Hooks) + *asTemplate = false + *hotReload = false + watcher = nil + + t.Cleanup(func() { + hooksFiles = savedHooksFiles + hooksDirectories = savedDirectories + configuredHookFiles = savedConfigured + explicitHookFiles = savedExplicit + hookDirectoryByFile = savedDirectoryByFile + hookLoadMetadataByFile = savedMetadata + hookLoadFailures = savedFailures + lastHooksLoadTime = savedLastLoad + loadedHooksFromFiles = savedLoaded + *asTemplate = savedTemplate + *hotReload = savedHotReload + watcher = savedWatcher + }) +} + +func writeHookIDs(t *testing.T, path string, ids ...string) { + t.Helper() + + hooks := make([]hook.Hook, 0, len(ids)) + for _, id := range ids { + hooks = append(hooks, hook.Hook{ID: id, ExecuteCommand: "/bin/true"}) + } + data, err := json.Marshal(hooks) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +func TestInitializeHookSourcesLoadsDirectory(t *testing.T) { + resetHookSourceTestState(t) + + directory := t.TempDir() + writeHookIDs(t, filepath.Join(directory, "one.json"), "alpha") + writeHookIDs(t, filepath.Join(directory, "two.YAML"), "beta") + writeHookIDs(t, filepath.Join(directory, "ignored.txt"), "ignored") + hooksDirectories = hook.HooksFiles{directory} + + if err := initializeHookSources(); err != nil { + t.Fatalf("initializeHookSources: %v", err) + } + if matchLoadedHook("alpha") == nil || matchLoadedHook("beta") == nil { + t.Fatalf("directory hooks were not loaded: %#v", loadedHooksFromFiles) + } + if matchLoadedHook("ignored") != nil { + t.Fatal("unsupported file extension was loaded") + } + if len(hooksFilesSnapshot()) != 2 { + t.Fatalf("loaded file count = %d, want 2", len(hooksFilesSnapshot())) + } +} + +func TestSyncHooksDirectoryAppliesChangesSafely(t *testing.T) { + resetHookSourceTestState(t) + + directory := t.TempDir() + first := filepath.Join(directory, "first.json") + second := filepath.Join(directory, "second.yaml") + writeHookIDs(t, first, "alpha") + hooksDirectories = hook.HooksFiles{directory} + if err := initializeHookSources(); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(first, []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + if err := syncHooksDirectory(directory); err != nil { + t.Fatal(err) + } + if matchLoadedHook("alpha") == nil { + t.Fatal("invalid update replaced the last valid hooks") + } + if len(hookLoadFailures) != 1 { + t.Fatalf("load failure count = %d, want 1", len(hookLoadFailures)) + } + + writeHookIDs(t, first, "beta") + writeHookIDs(t, second, "gamma") + if err := syncHooksDirectory(directory); err != nil { + t.Fatal(err) + } + if matchLoadedHook("alpha") != nil || matchLoadedHook("beta") == nil || matchLoadedHook("gamma") == nil { + t.Fatal("valid update and addition were not applied") + } + + if err := os.Remove(first); err != nil { + t.Fatal(err) + } + if err := syncHooksDirectory(directory); err != nil { + t.Fatal(err) + } + if matchLoadedHook("beta") != nil || matchLoadedHook("gamma") == nil { + t.Fatal("removed file hooks were not removed independently") + } +} + +func TestSyncHooksDirectoryRejectsDuplicateIDs(t *testing.T) { + resetHookSourceTestState(t) + + directory := t.TempDir() + writeHookIDs(t, filepath.Join(directory, "first.json"), "shared") + hooksDirectories = hook.HooksFiles{directory} + if err := initializeHookSources(); err != nil { + t.Fatal(err) + } + + writeHookIDs(t, filepath.Join(directory, "second.json"), "shared") + if err := syncHooksDirectory(directory); err != nil { + t.Fatal(err) + } + if lenLoadedHooks() != 1 { + t.Fatalf("loaded hooks = %d, want 1", lenLoadedHooks()) + } + if len(hookLoadFailures) != 1 { + t.Fatalf("load failure count = %d, want 1", len(hookLoadFailures)) + } +} + +func TestSyncHooksDirectoryAllowsIDTransferBetweenFiles(t *testing.T) { + resetHookSourceTestState(t) + + directory := t.TempDir() + oldPath := filepath.Join(directory, "old.json") + newPath := filepath.Join(directory, "new.json") + writeHookIDs(t, oldPath, "transferred") + hooksDirectories = hook.HooksFiles{directory} + if err := initializeHookSources(); err != nil { + t.Fatal(err) + } + if err := os.Rename(oldPath, newPath); err != nil { + t.Fatal(err) + } + if err := syncHooksDirectory(directory); err != nil { + t.Fatal(err) + } + + if matchLoadedHook("transferred") == nil { + t.Fatal("hook was lost while its source file was renamed") + } + files := hooksFilesSnapshot() + if len(files) != 1 || files[0] != newPath { + t.Fatalf("loaded sources = %v, want [%s]", files, newPath) + } +} + +func TestInitializeHookSourcesRejectsDuplicateExplicitFiles(t *testing.T) { + resetHookSourceTestState(t) + + directory := t.TempDir() + first := filepath.Join(directory, "first.json") + second := filepath.Join(directory, "second.json") + writeHookIDs(t, first, "shared") + writeHookIDs(t, second, "shared") + hooksFiles = hook.HooksFiles{first, second} + + if err := initializeHookSources(); err == nil { + t.Fatal("duplicate IDs in explicitly configured files were accepted") + } +} + +func TestDirectoryWatcherHandlesAddAndRemove(t *testing.T) { + resetHookSourceTestState(t) + + directory := t.TempDir() + hooksDirectories = hook.HooksFiles{directory} + if err := initializeHookSources(); err != nil { + t.Fatal(err) + } + if err := startHookWatcher(); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + go func() { + watchForFileChange() + close(done) + }() + + path := filepath.Join(directory, "dynamic.json") + writeHookIDs(t, path, "dynamic") + waitForHook(t, "dynamic", true) + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + waitForHook(t, "dynamic", false) + + if err := watcher.Close(); err != nil { + t.Fatal(err) + } + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("watcher goroutine did not stop") + } + watcher = nil +} + +func waitForHook(t *testing.T, id string, present bool) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if (matchLoadedHook(id) != nil) == present { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("hook %q presence did not become %t", id, present) +} + +func TestDirectoryHooksFilesRejectsEscapingSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation may require elevated privileges on Windows") + } + + directory := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside.json") + writeHookIDs(t, outside, "outside") + link := filepath.Join(directory, "escape.json") + if err := os.Symlink(outside, link); err != nil { + t.Skipf("cannot create symlink: %v", err) + } + + files, err := directoryHooksFiles(directory) + if err != nil { + t.Fatal(err) + } + if len(files) != 0 { + t.Fatalf("escaping symlink was accepted: %v", files) + } +} + +func TestStatusHandlerReportsHooksWithoutSensitiveConfiguration(t *testing.T) { + resetHookSourceTestState(t) + + path := filepath.Join(t.TempDir(), "private-hooks.json") + loadedAt := time.Date(2026, 8, 24, 10, 30, 0, 0, time.UTC) + loadedHooksFromFiles[path] = hook.Hooks{{ + ID: "deploy", + ExecuteCommand: "/secret/bin/deploy --token private-value", + }} + hooksFiles = hook.HooksFiles{path} + hookLoadMetadataByFile[path] = hookLoadMetadata{LoadedAt: loadedAt} + lastHooksLoadTime = loadedAt + + req := httptest.NewRequest(http.MethodGet, "/status", nil) + rec := httptest.NewRecorder() + statusHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status code = %d, want 200", rec.Code) + } + body := rec.Body.String() + for _, sensitive := range []string{filepath.Dir(path), "/secret/bin/deploy", "private-value"} { + if strings.Contains(body, sensitive) { + t.Fatalf("status response leaked %q: %s", sensitive, body) + } + } + if !strings.Contains(body, `"source":"private-hooks.json"`) || !strings.Contains(body, `"hooks":["deploy"]`) { + t.Fatalf("status response does not identify loaded hooks: %s", body) + } + if rec.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("Cache-Control = %q, want no-store", rec.Header().Get("Cache-Control")) + } +} diff --git a/images/img01.webp b/images/img01.webp new file mode 100644 index 00000000..f0729caa Binary files /dev/null and b/images/img01.webp differ diff --git a/images/img02.webp b/images/img02.webp new file mode 100644 index 00000000..4046071d Binary files /dev/null and b/images/img02.webp differ diff --git a/images/img03.webp b/images/img03.webp new file mode 100644 index 00000000..e2f98a21 Binary files /dev/null and b/images/img03.webp differ diff --git a/internal/hook/hook.go b/internal/hook/hook.go index 394dd799..bff97f57 100644 --- a/internal/hook/hook.go +++ b/internal/hook/hook.go @@ -281,26 +281,37 @@ func CheckScalrSignature(r *Request, signingKey string, checkDate bool) (bool, e // CheckIPWhitelist makes sure the provided remote address (of the form IP:port) falls within the provided IP range // (in CIDR form or a single IP address). func CheckIPWhitelist(remoteAddr, ipRange string) (bool, error) { - // Extract IP address from remote address. - - // IPv6 addresses will likely be surrounded by []. - ip := strings.Trim(remoteAddr, " []") - - if i := strings.LastIndex(ip, ":"); i != -1 { - ip = ip[:i] - ip = strings.Trim(ip, " []") + addr := strings.TrimSpace(remoteAddr) + var ipText string + if strings.HasPrefix(addr, "[") { + if end := strings.IndexByte(addr, ']'); end >= 0 { + ipText = strings.TrimSpace(addr[1:end]) + if host, _, err := net.SplitHostPort(ipText); err == nil { + ipText = host + } + } } - - parsedIP := net.ParseIP(ip) + if ipText == "" { + if host, _, err := net.SplitHostPort(addr); err == nil { + ipText = host + } else { + ipText = strings.Trim(addr, " []") + } + } + parsedIP := net.ParseIP(strings.TrimSpace(ipText)) if parsedIP == nil { return false, fmt.Errorf("invalid IP address found in remote address '%s'", remoteAddr) } - for _, r := range strings.Fields(ipRange) { + for _, r := range strings.FieldsFunc(ipRange, func(ch rune) bool { return ch == ',' || ch == ' ' || ch == '\t' || ch == '\n' }) { // Extract IP range in CIDR form. If a single IP address is provided, turn it into CIDR form. if !strings.Contains(r, "/") { - r = r + "/32" + if net.ParseIP(r).To4() != nil { + r += "/32" + } else { + r += "/128" + } } _, cidr, err := net.ParseCIDR(r) @@ -567,6 +578,8 @@ type Hook struct { ID string `json:"id,omitempty"` ExecuteCommand string `json:"execute-command,omitempty"` CommandWorkingDirectory string `json:"command-working-directory,omitempty"` + CommandTimeout string `json:"command-timeout,omitempty"` + MaxConcurrency *int `json:"max-concurrency,omitempty"` ResponseMessage string `json:"response-message,omitempty"` ResponseHeaders ResponseHeaders `json:"response-headers,omitempty"` CaptureCommandOutput bool `json:"include-command-output-in-response,omitempty"` @@ -581,6 +594,70 @@ type Hook struct { IncomingPayloadContentType string `json:"incoming-payload-content-type,omitempty"` SuccessHttpResponseCode int `json:"success-http-response-code,omitempty"` HTTPMethods []string `json:"http-methods"` + KeepFileEnvironment bool `json:"keep-file-environment,omitempty"` +} + +func parseCommandTimeout(value string) (time.Duration, error) { + value = strings.TrimSpace(value) + if value == "" || value == "0" { + return 0, nil + } + + timeout, err := time.ParseDuration(value) + if err != nil { + return 0, err + } + if timeout < 0 { + return 0, errors.New("must be zero or greater") + } + + return timeout, nil +} + +// ValidateExecutionSettings normalizes and validates hook execution limits. +func (h *Hook) ValidateExecutionSettings() error { + h.CommandTimeout = strings.TrimSpace(h.CommandTimeout) + + if h.CommandTimeout != "" { + if _, err := parseCommandTimeout(h.CommandTimeout); err != nil { + return fmt.Errorf("invalid command-timeout %q: %w", h.CommandTimeout, err) + } + } + + if h.MaxConcurrency != nil && *h.MaxConcurrency < 0 { + return fmt.Errorf("invalid max-concurrency %d: must be zero or greater", *h.MaxConcurrency) + } + + return nil +} + +// EffectiveCommandTimeout returns the hook timeout or the inherited default. +func (h Hook) EffectiveCommandTimeout(defaultTimeout time.Duration) (time.Duration, error) { + if defaultTimeout < 0 { + return 0, errors.New("default command timeout must be zero or greater") + } + + if h.CommandTimeout == "" { + return defaultTimeout, nil + } + + return parseCommandTimeout(h.CommandTimeout) +} + +// EffectiveMaxConcurrency returns the hook concurrency limit or the inherited default. +func (h Hook) EffectiveMaxConcurrency(defaultLimit int) (int, error) { + if defaultLimit < 0 { + return 0, errors.New("default max concurrency must be zero or greater") + } + + if h.MaxConcurrency == nil { + return defaultLimit, nil + } + if *h.MaxConcurrency < 0 { + return 0, errors.New("max-concurrency must be zero or greater") + } + + return *h.MaxConcurrency, nil } // ParseJSONParameters decodes specified arguments to JSON objects and replaces the @@ -778,7 +855,21 @@ func (h *Hooks) LoadFromFile(path string, asTemplate bool) error { file = buf.Bytes() } - return yaml.Unmarshal(file, h) + if err := yaml.Unmarshal(file, h); err != nil { + return err + } + + for i := range *h { + if err := (*h)[i].ValidateExecutionSettings(); err != nil { + id := (*h)[i].ID + if id == "" { + id = fmt.Sprintf("#%d", i) + } + return fmt.Errorf("invalid hook %q: %w", id, err) + } + } + + return nil } // Append appends hooks unless the new hooks contain a hook with an ID that already exists @@ -915,7 +1006,11 @@ const ( // Evaluate MatchRule will return based on the type func (r MatchRule) Evaluate(req *Request) (bool, error) { if r.Type == IPWhitelist { - return CheckIPWhitelist(req.RawRequest.RemoteAddr, r.IPRange) + addr := req.RealIP + if addr == "" { + addr = req.RawRequest.RemoteAddr + } + return CheckIPWhitelist(addr, r.IPRange) } if r.Type == ScalrSignature { return CheckScalrSignature(req, r.Secret, true) diff --git a/internal/hook/hook_test.go b/internal/hook/hook_test.go index cbc49f70..213c280d 100644 --- a/internal/hook/hook_test.go +++ b/internal/hook/hook_test.go @@ -6,6 +6,7 @@ import ( "reflect" "strings" "testing" + "time" ) func TestGetParameter(t *testing.T) { @@ -351,20 +352,21 @@ func TestHookExtractCommandArguments(t *testing.T) { // we test both cases where the name of the data is used as the name of the // env key & the case where the hook definition sets the env var name to a // fixed value using the envname construct like so:: -// [ -// { -// "id": "push", -// "execute-command": "bb2mm", -// "command-working-directory": "/tmp", -// "pass-environment-to-command": -// [ -// { -// "source": "entire-payload", -// "envname": "PAYLOAD" -// }, -// ] -// } -// ] +// +// [ +// { +// "id": "push", +// "execute-command": "bb2mm", +// "command-working-directory": "/tmp", +// "pass-environment-to-command": +// [ +// { +// "source": "entire-payload", +// "envname": "PAYLOAD" +// }, +// ] +// } +// ] var hookExtractCommandArgumentsForEnvTests = []struct { exec string args []Argument @@ -412,6 +414,108 @@ func TestHookExtractCommandArgumentsForEnv(t *testing.T) { } } +func intPtr(v int) *int { + return &v +} + +func TestHookValidateExecutionSettings(t *testing.T) { + for _, tt := range []struct { + name string + hook Hook + wantErr bool + }{ + { + name: "valid values", + hook: Hook{CommandTimeout: "250ms", MaxConcurrency: intPtr(2)}, + }, + { + name: "explicit unlimited values", + hook: Hook{CommandTimeout: "0", MaxConcurrency: intPtr(0)}, + }, + { + name: "invalid timeout", + hook: Hook{CommandTimeout: "not-a-duration"}, + wantErr: true, + }, + { + name: "invalid concurrency", + hook: Hook{MaxConcurrency: intPtr(-1)}, + wantErr: true, + }, + } { + err := tt.hook.ValidateExecutionSettings() + if (err != nil) != tt.wantErr { + t.Fatalf("%s: expected error=%v, got err=%v", tt.name, tt.wantErr, err) + } + } +} + +func TestHookEffectiveExecutionSettings(t *testing.T) { + for _, tt := range []struct { + name string + hook Hook + defaultTimeout time.Duration + defaultConcurrency int + wantTimeout time.Duration + wantConcurrency int + wantTimeoutErr bool + wantConcurrencyErr bool + }{ + { + name: "inherits defaults", + hook: Hook{}, + defaultTimeout: 5 * time.Second, + defaultConcurrency: 3, + wantTimeout: 5 * time.Second, + wantConcurrency: 3, + }, + { + name: "hook overrides defaults", + hook: Hook{CommandTimeout: "250ms", MaxConcurrency: intPtr(1)}, + defaultTimeout: 5 * time.Second, + defaultConcurrency: 3, + wantTimeout: 250 * time.Millisecond, + wantConcurrency: 1, + }, + { + name: "hook disables defaults", + hook: Hook{CommandTimeout: "0", MaxConcurrency: intPtr(0)}, + defaultTimeout: 5 * time.Second, + defaultConcurrency: 3, + wantTimeout: 0, + wantConcurrency: 0, + }, + { + name: "invalid timeout override", + hook: Hook{CommandTimeout: "bogus"}, + wantTimeoutErr: true, + wantConcurrency: 0, + }, + { + name: "invalid default concurrency", + hook: Hook{}, + defaultConcurrency: -1, + wantConcurrencyErr: true, + }, + } { + timeout, timeoutErr := tt.hook.EffectiveCommandTimeout(tt.defaultTimeout) + if (timeoutErr != nil) != tt.wantTimeoutErr { + t.Fatalf("%s: expected timeout error=%v, got err=%v", tt.name, tt.wantTimeoutErr, timeoutErr) + } + if timeoutErr == nil && timeout != tt.wantTimeout { + t.Fatalf("%s: expected timeout %v, got %v", tt.name, tt.wantTimeout, timeout) + } + + concurrency, concurrencyErr := tt.hook.EffectiveMaxConcurrency(tt.defaultConcurrency) + if (concurrencyErr != nil) != tt.wantConcurrencyErr { + t.Fatalf("%s: expected concurrency error=%v, got err=%v", tt.name, tt.wantConcurrencyErr, concurrencyErr) + } + if concurrencyErr == nil && concurrency != tt.wantConcurrency { + t.Fatalf("%s: expected concurrency %d, got %d", tt.name, tt.wantConcurrency, concurrency) + } + } +} + var hooksLoadFromFileTests = []struct { path string asTemplate bool diff --git a/internal/hook/request.go b/internal/hook/request.go index 6e0bd0c7..df1f00ed 100644 --- a/internal/hook/request.go +++ b/internal/hook/request.go @@ -36,6 +36,11 @@ type Request struct { // Treat signature errors as simple validate failures. AllowSignatureErrors bool + + // RealIP is the resolved client IP address. When the request comes through + // a trusted reverse proxy, this is extracted from the configured header + // (e.g. X-Real-Ip). Otherwise it equals RawRequest.RemoteAddr. + RealIP string } func (r *Request) ParseJSONPayload() error { diff --git a/internal/update/replace_unix.go b/internal/update/replace_unix.go new file mode 100644 index 00000000..f5031d48 --- /dev/null +++ b/internal/update/replace_unix.go @@ -0,0 +1,13 @@ +//go:build !windows + +package update + +import "os" + +func replaceExecutable(source, target string) error { + return os.Rename(source, target) +} + +func replaceFile(source, target string) error { + return os.Rename(source, target) +} diff --git a/internal/update/replace_windows.go b/internal/update/replace_windows.go new file mode 100644 index 00000000..e30e9416 --- /dev/null +++ b/internal/update/replace_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package update + +import "os" + +func replaceExecutable(source, target string) error { + old := target + ".replacing" + _ = os.Remove(old) + if err := os.Rename(target, old); err != nil { + return err + } + if err := os.Rename(source, target); err != nil { + _ = os.Rename(old, target) + return err + } + return os.Remove(old) +} + +func replaceFile(source, target string) error { + if err := os.Remove(target); err != nil && !os.IsNotExist(err) { + return err + } + return os.Rename(source, target) +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 00000000..bb446611 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,729 @@ +package update + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +const ( + DefaultRepository = "xtulnx/webhook" + manifestName = "update-manifest.json" + signatureName = "update-manifest.json.sig" + maxManifestSize = 2 << 20 + maxArchiveSize = 512 << 20 + maxBinarySize = 256 << 20 +) + +var ErrSignatureUnavailable = errors.New("update signature verification is not configured") + +type Asset struct { + OS string `json:"os"` + Arch string `json:"arch"` + Name string `json:"name"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +type Manifest struct { + SchemaVersion int `json:"schemaVersion"` + Version string `json:"version"` + PublishedAt time.Time `json:"publishedAt"` + Commit string `json:"commit,omitempty"` + ReleaseURL string `json:"releaseURL,omitempty"` + Assets []Asset `json:"assets"` +} + +type Result struct { + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion,omitempty"` + Available bool `json:"available"` + Verified bool `json:"verified"` + PublishedAt time.Time `json:"publishedAt,omitempty"` + ReleaseURL string `json:"releaseURL,omitempty"` + CheckedAt time.Time `json:"checkedAt"` + Manifest Manifest `json:"-"` +} + +type State struct { + CurrentVersion string `json:"currentVersion"` + InstalledVersion string `json:"installedVersion"` + Target string `json:"target"` + Backup string `json:"backup"` + SHA256 string `json:"sha256"` + AppliedAt time.Time `json:"appliedAt"` + RolledBackAt time.Time `json:"rolledBackAt,omitempty"` +} + +type Client struct { + Repository string + Version string + PublicKey string + HTTPClient *http.Client + BaseURL string +} + +type ApplyOptions struct { + Version string + Target string + StateDir string + GOOS string + GOARCH string + SkipProbe bool + AllowUnsigned bool +} + +func (c *Client) Check(ctx context.Context, requestedVersion string) (Result, error) { + if err := validateRepository(c.repository()); err != nil { + return Result{}, err + } + if err := validateRequestedVersion(requestedVersion); err != nil { + return Result{}, err + } + + manifestURL := c.releaseAssetURL(requestedVersion, manifestName) + manifestBytes, err := c.download(ctx, manifestURL, maxManifestSize) + if err != nil { + return Result{}, fmt.Errorf("download update manifest: %w", err) + } + + verified := false + if strings.TrimSpace(c.PublicKey) != "" { + signatureBytes, err := c.download(ctx, c.releaseAssetURL(requestedVersion, signatureName), maxManifestSize) + if err != nil { + return Result{}, fmt.Errorf("download update manifest signature: %w", err) + } + if err := verifyManifestSignature(manifestBytes, signatureBytes, c.PublicKey); err != nil { + return Result{}, err + } + verified = true + } + + var manifest Manifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + return Result{}, fmt.Errorf("decode update manifest: %w", err) + } + if err := validateManifest(manifest); err != nil { + return Result{}, err + } + if requestedVersion != "" && requestedVersion != "latest" && CompareVersions(manifest.Version, requestedVersion) != 0 { + return Result{}, fmt.Errorf("update manifest version %s does not match requested version %s", manifest.Version, requestedVersion) + } + + return Result{ + CurrentVersion: c.Version, + LatestVersion: manifest.Version, + Available: CompareVersions(manifest.Version, c.Version) > 0, + Verified: verified, + PublishedAt: manifest.PublishedAt, + ReleaseURL: manifest.ReleaseURL, + CheckedAt: time.Now().UTC(), + Manifest: manifest, + }, nil +} + +func (c *Client) Apply(ctx context.Context, options ApplyOptions) (State, error) { + if strings.TrimSpace(c.PublicKey) == "" && !options.AllowUnsigned { + return State{}, ErrSignatureUnavailable + } + + result, err := c.Check(ctx, options.Version) + if err != nil { + return State{}, err + } + if !result.Verified && !options.AllowUnsigned { + return State{}, ErrSignatureUnavailable + } + if options.Version == "" && !result.Available { + return State{}, fmt.Errorf("version %s is not newer than %s", result.LatestVersion, c.Version) + } + + goos := options.GOOS + if goos == "" { + goos = runtime.GOOS + } + goarch := options.GOARCH + if goarch == "" { + goarch = runtime.GOARCH + } + asset, err := findAsset(result.Manifest, goos, goarch) + if err != nil { + return State{}, err + } + + target, err := resolveTarget(options.Target) + if err != nil { + return State{}, err + } + stateDir := strings.TrimSpace(options.StateDir) + if stateDir == "" { + stateDir, err = os.Getwd() + if err != nil { + return State{}, fmt.Errorf("locate update state directory: %w", err) + } + } + stateDir, err = filepath.Abs(stateDir) + if err != nil { + return State{}, fmt.Errorf("resolve update state directory: %w", err) + } + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return State{}, fmt.Errorf("create update state directory: %w", err) + } + + archive, err := os.CreateTemp(stateDir, ".webhook-update-*.tar.gz") + if err != nil { + return State{}, fmt.Errorf("create update archive: %w", err) + } + archivePath := archive.Name() + defer os.Remove(archivePath) + + if err := c.downloadFile(ctx, c.releaseAssetURL(result.Manifest.Version, asset.Name), archive, maxArchiveSize); err != nil { + archive.Close() + return State{}, fmt.Errorf("download update archive: %w", err) + } + if err := archive.Close(); err != nil { + return State{}, fmt.Errorf("close update archive: %w", err) + } + if err := verifyFile(archivePath, asset); err != nil { + return State{}, err + } + + newBinary, err := extractBinary(archivePath, filepath.Dir(target), asset, goos, goarch) + if err != nil { + return State{}, err + } + defer os.Remove(newBinary) + + if info, statErr := os.Stat(target); statErr == nil { + if err := os.Chmod(newBinary, info.Mode().Perm()); err != nil { + return State{}, fmt.Errorf("set update binary permissions: %w", err) + } + } else if err := os.Chmod(newBinary, 0o755); err != nil { + return State{}, fmt.Errorf("set update binary permissions: %w", err) + } + if !options.SkipProbe { + if err := probeBinary(newBinary, result.Manifest.Version); err != nil { + return State{}, err + } + } + + backup := target + ".previous" + if err := copyFile(target, backup); err != nil { + return State{}, fmt.Errorf("backup current binary: %w", err) + } + if err := replaceExecutable(newBinary, target); err != nil { + return State{}, fmt.Errorf("replace executable: %w", err) + } + + state := State{ + CurrentVersion: c.Version, + InstalledVersion: result.Manifest.Version, + Target: target, + Backup: backup, + SHA256: strings.ToLower(asset.SHA256), + AppliedAt: time.Now().UTC(), + } + if err := writeState(stateDir, state); err != nil { + return state, fmt.Errorf("update installed but state could not be written: %w", err) + } + return state, nil +} + +func Rollback(target, stateDir string) (State, error) { + resolvedTarget, err := resolveTarget(target) + if err != nil { + return State{}, err + } + if strings.TrimSpace(stateDir) == "" { + stateDir, err = os.Getwd() + if err != nil { + return State{}, fmt.Errorf("locate update state directory: %w", err) + } + } + state, err := readState(stateDir) + if err != nil { + return State{}, err + } + if state.Target != resolvedTarget { + return State{}, fmt.Errorf("update state belongs to %s, not %s", state.Target, resolvedTarget) + } + if _, err := os.Stat(state.Backup); err != nil { + return State{}, fmt.Errorf("access rollback binary: %w", err) + } + + tmp, err := os.CreateTemp(filepath.Dir(resolvedTarget), ".webhook-rollback-*") + if err != nil { + return State{}, fmt.Errorf("create rollback file: %w", err) + } + tmpPath := tmp.Name() + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return State{}, err + } + defer os.Remove(tmpPath) + if err := copyFile(state.Backup, tmpPath); err != nil { + return State{}, fmt.Errorf("prepare rollback binary: %w", err) + } + if err := replaceExecutable(tmpPath, resolvedTarget); err != nil { + return State{}, fmt.Errorf("restore previous binary: %w", err) + } + + state.RolledBackAt = time.Now().UTC() + if err := writeState(stateDir, state); err != nil { + return state, fmt.Errorf("rollback completed but state could not be written: %w", err) + } + return state, nil +} + +func (c *Client) repository() string { + repository := strings.TrimSpace(c.Repository) + if repository == "" { + return DefaultRepository + } + return repository +} + +func (c *Client) baseURL() string { + if strings.TrimSpace(c.BaseURL) != "" { + return strings.TrimRight(c.BaseURL, "/") + } + return "https://github.com/" + c.repository() +} + +func (c *Client) releaseAssetURL(version, asset string) string { + version = strings.TrimSpace(version) + if version == "" || version == "latest" { + return c.baseURL() + "/releases/latest/download/" + asset + } + if !strings.HasPrefix(version, "v") { + version = "v" + version + } + return c.baseURL() + "/releases/download/" + version + "/" + asset +} + +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return &http.Client{Timeout: 30 * time.Second} +} + +func (c *Client) download(ctx context.Context, url string, limit int64) ([]byte, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + request.Header.Set("User-Agent", "webhook-updater/"+c.Version) + response, err := c.httpClient().Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected HTTP status %s", response.Status) + } + data, err := io.ReadAll(io.LimitReader(response.Body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, fmt.Errorf("response exceeds %d bytes", limit) + } + return data, nil +} + +func (c *Client) downloadFile(ctx context.Context, url string, destination io.Writer, limit int64) error { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + request.Header.Set("User-Agent", "webhook-updater/"+c.Version) + response, err := c.httpClient().Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected HTTP status %s", response.Status) + } + if response.ContentLength > limit { + return fmt.Errorf("archive exceeds %d bytes", limit) + } + written, err := io.Copy(destination, io.LimitReader(response.Body, limit+1)) + if err != nil { + return err + } + if written > limit { + return fmt.Errorf("archive exceeds %d bytes", limit) + } + return nil +} + +func verifyManifestSignature(manifest, encodedSignature []byte, encodedPublicKey string) error { + publicKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encodedPublicKey)) + if err != nil || len(publicKey) != ed25519.PublicKeySize { + return errors.New("invalid update manifest public key") + } + signature, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(encodedSignature))) + if err != nil || len(signature) != ed25519.SignatureSize { + return errors.New("invalid update manifest signature encoding") + } + if !ed25519.Verify(ed25519.PublicKey(publicKey), manifest, signature) { + return errors.New("update manifest signature verification failed") + } + return nil +} + +func validateManifest(manifest Manifest) error { + if manifest.SchemaVersion != 1 { + return fmt.Errorf("unsupported update manifest schema %d", manifest.SchemaVersion) + } + if _, ok := parseVersion(manifest.Version); !ok { + return fmt.Errorf("invalid release version %q", manifest.Version) + } + if len(manifest.Assets) == 0 { + return errors.New("update manifest contains no assets") + } + for _, asset := range manifest.Assets { + if asset.OS == "" || asset.Arch == "" || filepath.Base(asset.Name) != asset.Name || asset.Size <= 0 { + return fmt.Errorf("invalid update asset %q", asset.Name) + } + decoded, err := hex.DecodeString(asset.SHA256) + if err != nil || len(decoded) != sha256.Size { + return fmt.Errorf("invalid SHA256 for update asset %q", asset.Name) + } + } + return nil +} + +func validateRepository(repository string) error { + parts := strings.Split(repository, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("invalid update repository %q", repository) + } + for _, part := range parts { + for _, ch := range part { + if !(ch >= 'a' && ch <= 'z') && !(ch >= 'A' && ch <= 'Z') && !(ch >= '0' && ch <= '9') && ch != '-' && ch != '_' && ch != '.' { + return fmt.Errorf("invalid update repository %q", repository) + } + } + } + return nil +} + +func validateRequestedVersion(version string) error { + version = strings.TrimSpace(version) + if version == "" || version == "latest" { + return nil + } + if _, ok := parseVersion(version); !ok { + return fmt.Errorf("invalid requested update version %q", version) + } + return nil +} + +func findAsset(manifest Manifest, goos, goarch string) (Asset, error) { + for _, asset := range manifest.Assets { + if asset.OS == goos && asset.Arch == goarch { + return asset, nil + } + } + return Asset{}, fmt.Errorf("release %s has no asset for %s/%s", manifest.Version, goos, goarch) +} + +func verifyFile(path string, asset Asset) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + hash := sha256.New() + size, err := io.Copy(hash, file) + if err != nil { + return err + } + if size != asset.Size { + return fmt.Errorf("update archive size mismatch: got %d, want %d", size, asset.Size) + } + actual := hex.EncodeToString(hash.Sum(nil)) + if !strings.EqualFold(actual, asset.SHA256) { + return errors.New("update archive SHA256 mismatch") + } + return nil +} + +func extractBinary(archivePath, destinationDir string, asset Asset, goos, goarch string) (string, error) { + archive, err := os.Open(archivePath) + if err != nil { + return "", err + } + defer archive.Close() + gzipReader, err := gzip.NewReader(archive) + if err != nil { + return "", fmt.Errorf("open update archive: %w", err) + } + defer gzipReader.Close() + + binaryName := "webhook" + if goos == "windows" { + binaryName += ".exe" + } + wanted := filepath.ToSlash("webhook-" + goos + "-" + goarch + "/" + binaryName) + tarReader := tar.NewReader(gzipReader) + for { + header, err := tarReader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", fmt.Errorf("read update archive: %w", err) + } + cleanName := filepath.ToSlash(filepath.Clean(header.Name)) + if cleanName != wanted { + continue + } + if header.Typeflag != tar.TypeReg || header.Size <= 0 || header.Size > maxBinarySize { + return "", errors.New("update archive contains an invalid executable") + } + tmp, err := os.CreateTemp(destinationDir, ".webhook-new-*") + if err != nil { + return "", err + } + tmpPath := tmp.Name() + written, copyErr := io.Copy(tmp, io.LimitReader(tarReader, maxBinarySize+1)) + closeErr := tmp.Close() + if copyErr != nil || closeErr != nil || written != header.Size { + os.Remove(tmpPath) + if copyErr != nil { + return "", copyErr + } + if closeErr != nil { + return "", closeErr + } + return "", errors.New("update executable size mismatch") + } + return tmpPath, nil + } + return "", fmt.Errorf("update archive does not contain %s", wanted) +} + +func resolveTarget(target string) (string, error) { + var err error + if strings.TrimSpace(target) == "" { + target, err = os.Executable() + if err != nil { + return "", fmt.Errorf("locate current executable: %w", err) + } + } + target, err = filepath.Abs(target) + if err != nil { + return "", fmt.Errorf("resolve update target: %w", err) + } + if resolved, evalErr := filepath.EvalSymlinks(target); evalErr == nil { + target = resolved + } + return target, nil +} + +func probeBinary(path, expectedVersion string) error { + command := exec.Command(path, "-version") + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("run downloaded executable: %w", err) + } + if !strings.Contains(string(output), strings.TrimPrefix(expectedVersion, "v")) { + return fmt.Errorf("downloaded executable reported an unexpected version: %s", strings.TrimSpace(string(output))) + } + return nil +} + +func copyFile(source, destination string) error { + src, err := os.Open(source) + if err != nil { + return err + } + defer src.Close() + info, err := src.Stat() + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(destination), filepath.Base(destination)+".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(info.Mode().Perm()); err != nil { + tmp.Close() + return err + } + if _, err := io.Copy(tmp, src); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return replaceFile(tmpPath, destination) +} + +func statePath(stateDir string) string { + return filepath.Join(stateDir, ".webhook-update.json") +} + +func writeState(stateDir string, state State) error { + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + tmp, err := os.CreateTemp(stateDir, ".webhook-update-state-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return replaceFile(tmpPath, statePath(stateDir)) +} + +func readState(stateDir string) (State, error) { + data, err := os.ReadFile(statePath(stateDir)) + if err != nil { + return State{}, fmt.Errorf("read update state: %w", err) + } + var state State + if err := json.Unmarshal(data, &state); err != nil { + return State{}, fmt.Errorf("decode update state: %w", err) + } + return state, nil +} + +type parsedVersion struct { + major int + minor int + patch int + pre []string +} + +func CompareVersions(left, right string) int { + l, lok := parseVersion(left) + r, rok := parseVersion(right) + if !lok && !rok { + return strings.Compare(left, right) + } + if !lok { + return -1 + } + if !rok { + return 1 + } + for _, pair := range [][2]int{{l.major, r.major}, {l.minor, r.minor}, {l.patch, r.patch}} { + if pair[0] < pair[1] { + return -1 + } + if pair[0] > pair[1] { + return 1 + } + } + if len(l.pre) == 0 && len(r.pre) != 0 { + return 1 + } + if len(l.pre) != 0 && len(r.pre) == 0 { + return -1 + } + for i := 0; i < len(l.pre) && i < len(r.pre); i++ { + if l.pre[i] == r.pre[i] { + continue + } + li, lerr := strconv.Atoi(l.pre[i]) + ri, rerr := strconv.Atoi(r.pre[i]) + switch { + case lerr == nil && rerr == nil: + if li < ri { + return -1 + } + return 1 + case lerr == nil: + return -1 + case rerr == nil: + return 1 + default: + return strings.Compare(l.pre[i], r.pre[i]) + } + } + if len(l.pre) < len(r.pre) { + return -1 + } + if len(l.pre) > len(r.pre) { + return 1 + } + return 0 +} + +func parseVersion(value string) (parsedVersion, bool) { + value = strings.TrimSpace(strings.TrimPrefix(value, "v")) + if buildIndex := strings.IndexByte(value, '+'); buildIndex >= 0 { + value = value[:buildIndex] + } + var pre []string + if preIndex := strings.IndexByte(value, '-'); preIndex >= 0 { + pre = strings.Split(value[preIndex+1:], ".") + value = value[:preIndex] + } + parts := strings.Split(value, ".") + if len(parts) != 3 { + return parsedVersion{}, false + } + numbers := make([]int, 3) + for i, part := range parts { + if part == "" || (len(part) > 1 && part[0] == '0') { + return parsedVersion{}, false + } + number, err := strconv.Atoi(part) + if err != nil || number < 0 { + return parsedVersion{}, false + } + numbers[i] = number + } + for _, identifier := range pre { + if identifier == "" { + return parsedVersion{}, false + } + } + return parsedVersion{major: numbers[0], minor: numbers[1], patch: numbers[2], pre: pre}, true +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 00000000..9036f7ea --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,210 @@ +package update + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestCompareVersions(t *testing.T) { + tests := []struct { + left string + right string + want int + }{ + {left: "v2.8.4", right: "2.8.3", want: 1}, + {left: "2.8.3", right: "v2.8.3", want: 0}, + {left: "2.8.3-rc.1", right: "2.8.3", want: -1}, + {left: "2.8.3-rc.2", right: "2.8.3-rc.1", want: 1}, + {left: "dev", right: "2.8.3", want: -1}, + } + for _, test := range tests { + if got := CompareVersions(test.left, test.right); normalizeComparison(got) != test.want { + t.Errorf("CompareVersions(%q, %q) = %d, want %d", test.left, test.right, got, test.want) + } + } +} + +func TestCheckVerifiesSignedManifest(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + manifest := testManifest(t, Asset{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Name: "webhook-test.tar.gz", + Size: 1, + SHA256: hex.EncodeToString(make([]byte, sha256.Size)), + }) + signature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifest)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch filepath.Base(r.URL.Path) { + case manifestName: + _, _ = w.Write(manifest) + case signatureName: + _, _ = w.Write([]byte(signature)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := Client{ + Repository: DefaultRepository, + Version: "2.8.3", + PublicKey: base64.StdEncoding.EncodeToString(publicKey), + HTTPClient: server.Client(), + BaseURL: server.URL, + } + result, err := client.Check(context.Background(), "") + if err != nil { + t.Fatalf("Check: %v", err) + } + if !result.Verified || !result.Available || result.LatestVersion != "v2.8.4" { + t.Fatalf("unexpected check result: %+v", result) + } +} + +func TestApplyAndRollback(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test executable is a POSIX shell script") + } + + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + binary := []byte("#!/bin/sh\necho 'webhook version 2.8.4'\n") + archive := testArchive(t, runtime.GOOS, runtime.GOARCH, binary) + digest := sha256.Sum256(archive) + asset := Asset{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Name: "webhook-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz", + Size: int64(len(archive)), + SHA256: hex.EncodeToString(digest[:]), + } + manifest := testManifest(t, asset) + signature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifest)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch filepath.Base(r.URL.Path) { + case manifestName: + _, _ = w.Write(manifest) + case signatureName: + _, _ = w.Write([]byte(signature)) + case asset.Name: + _, _ = w.Write(archive) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + directory := t.TempDir() + target := filepath.Join(directory, "webhook") + oldBinary := []byte("#!/bin/sh\necho 'webhook version 2.8.3'\n") + if err := os.WriteFile(target, oldBinary, 0o755); err != nil { + t.Fatal(err) + } + + client := Client{ + Repository: DefaultRepository, + Version: "2.8.3", + PublicKey: base64.StdEncoding.EncodeToString(publicKey), + HTTPClient: server.Client(), + BaseURL: server.URL, + } + state, err := client.Apply(context.Background(), ApplyOptions{Target: target, StateDir: directory}) + if err != nil { + t.Fatalf("Apply: %v", err) + } + if state.InstalledVersion != "v2.8.4" { + t.Fatalf("installed version = %q", state.InstalledVersion) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, binary) { + t.Fatal("target does not contain the new binary") + } + + if _, err := Rollback(target, directory); err != nil { + t.Fatalf("Rollback: %v", err) + } + got, err = os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, oldBinary) { + t.Fatal("target does not contain the restored binary") + } +} + +func testManifest(t *testing.T, asset Asset) []byte { + t.Helper() + manifest := Manifest{ + SchemaVersion: 1, + Version: "v2.8.4", + PublishedAt: time.Date(2026, 8, 28, 0, 0, 0, 0, time.UTC), + Commit: "abc123", + ReleaseURL: "https://github.com/xtulnx/webhook/releases/tag/v2.8.4", + Assets: []Asset{asset}, + } + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + return data +} + +func testArchive(t *testing.T, goos, goarch string, binary []byte) []byte { + t.Helper() + var buffer bytes.Buffer + gzipWriter := gzip.NewWriter(&buffer) + tarWriter := tar.NewWriter(gzipWriter) + name := "webhook-" + goos + "-" + goarch + "/webhook" + if goos == "windows" { + name += ".exe" + } + if err := tarWriter.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(binary)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := tarWriter.Write(binary); err != nil { + t.Fatal(err) + } + if err := tarWriter.Close(); err != nil { + t.Fatal(err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +func normalizeComparison(value int) int { + if value < 0 { + return -1 + } + if value > 0 { + return 1 + } + return 0 +} diff --git a/realip.go b/realip.go new file mode 100644 index 00000000..2b725d87 --- /dev/null +++ b/realip.go @@ -0,0 +1,135 @@ +package main + +import ( + "fmt" + "net" + "net/http" + "strings" +) + +// parsedTrustedProxies holds the parsed CIDR networks from the --trusted-proxies flag. +var parsedTrustedProxies []*net.IPNet +var parsedAccessWhitelist []*net.IPNet +var parsedAccessBlacklist []*net.IPNet + +// initTrustedProxies parses the --trusted-proxies flag value into CIDR networks. +// Must be called after flag.Parse(). +func parseIPNetworks(value, name string) ([]*net.IPNet, error) { + var networks []*net.IPNet + for _, entry := range strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' }) { + if !strings.Contains(entry, "/") { + ip := net.ParseIP(entry) + if ip == nil { + return nil, fmt.Errorf("invalid %s entry %q", name, entry) + } + if ip.To4() != nil { + entry += "/32" + } else { + entry += "/128" + } + } + _, cidr, err := net.ParseCIDR(entry) + if err != nil { + return nil, fmt.Errorf("invalid %s entry %q: %w", name, entry, err) + } + networks = append(networks, cidr) + } + return networks, nil +} + +func initTrustedProxies() error { + var err error + parsedTrustedProxies, err = parseIPNetworks(*trustedProxies, "trusted-proxies") + return err +} + +func initAccessControl() error { + if strings.TrimSpace(*accessWhitelist) != "" && strings.TrimSpace(*accessBlacklist) != "" { + return fmt.Errorf("access-whitelist and access-blacklist cannot be used together") + } + var err error + parsedAccessWhitelist, err = parseIPNetworks(*accessWhitelist, "access-whitelist") + if err != nil { + return err + } + parsedAccessBlacklist, err = parseIPNetworks(*accessBlacklist, "access-blacklist") + return err +} + +func accessControlMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := extractIP(resolveRealIP(r)) + allowed := true + if len(parsedAccessWhitelist) > 0 { + allowed = ip != nil && containsIP(parsedAccessWhitelist, ip) + } else if len(parsedAccessBlacklist) > 0 { + allowed = ip != nil && !containsIP(parsedAccessBlacklist, ip) + } + if !allowed { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("Access denied.")) + return + } + next.ServeHTTP(w, r) + }) +} + +func containsIP(networks []*net.IPNet, ip net.IP) bool { + for _, network := range networks { + if network.Contains(ip) { + return true + } + } + return false +} + +// isTrustedProxy checks whether the given remote address (IP:port) belongs to +// a trusted proxy network. +func isTrustedProxy(remoteAddr string) bool { + if len(parsedTrustedProxies) == 0 { + return false + } + + ip := extractIP(remoteAddr) + if ip == nil { + return false + } + + for _, cidr := range parsedTrustedProxies { + if cidr.Contains(ip) { + return true + } + } + return false +} + +// resolveRealIP determines the real client IP address. If the request comes +// from a trusted proxy and --real-ip-header is configured, the IP is read from +// that header. Otherwise the TCP remote address is used. +func resolveRealIP(r *http.Request) string { + if *realIPHeader != "" && isTrustedProxy(r.RemoteAddr) { + headerVal := strings.TrimSpace(r.Header.Get(*realIPHeader)) + if headerVal != "" { + // X-Forwarded-For may contain multiple IPs; take the first one. + if idx := strings.IndexByte(headerVal, ','); idx != -1 { + headerVal = strings.TrimSpace(headerVal[:idx]) + } + return headerVal + } + } + return r.RemoteAddr +} + +// extractIP parses an IP from an address string that may include a port. +func extractIP(addr string) net.IP { + addr = strings.Trim(addr, " []") + + // Try host:port split first. + host, _, err := net.SplitHostPort(addr) + if err == nil { + return net.ParseIP(host) + } + + return net.ParseIP(addr) +} diff --git a/realip_test.go b/realip_test.go new file mode 100644 index 00000000..41743bc5 --- /dev/null +++ b/realip_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestInitAccessControl(t *testing.T) { + savedWhitelist, savedBlacklist := *accessWhitelist, *accessBlacklist + savedWL, savedBL := parsedAccessWhitelist, parsedAccessBlacklist + defer func() { + *accessWhitelist, *accessBlacklist = savedWhitelist, savedBlacklist + parsedAccessWhitelist, parsedAccessBlacklist = savedWL, savedBL + }() + + *accessWhitelist, *accessBlacklist = "10.0.0.0/8", "" + if err := initAccessControl(); err != nil { + t.Fatal(err) + } + if len(parsedAccessWhitelist) != 1 { + t.Fatalf("whitelist entries = %d, want 1", len(parsedAccessWhitelist)) + } + + *accessWhitelist, *accessBlacklist = "10.0.0.1", "192.0.2.1" + if err := initAccessControl(); err == nil { + t.Fatal("expected whitelist/blacklist conflict") + } +} + +func TestAccessControlMiddleware(t *testing.T) { + savedWhitelist, savedBlacklist := *accessWhitelist, *accessBlacklist + savedWL, savedBL := parsedAccessWhitelist, parsedAccessBlacklist + defer func() { + *accessWhitelist, *accessBlacklist = savedWhitelist, savedBlacklist + parsedAccessWhitelist, parsedAccessBlacklist = savedWL, savedBL + }() + + *accessWhitelist, *accessBlacklist = "192.0.2.0/24", "" + if err := initAccessControl(); err != nil { + t.Fatal(err) + } + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) + handler := accessControlMiddleware(next) + + allowed := httptest.NewRequest(http.MethodGet, "/", nil) + allowed.RemoteAddr = "192.0.2.10:1234" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, allowed) + if rec.Code != http.StatusNoContent { + t.Fatalf("allowed status = %d", rec.Code) + } + + denied := httptest.NewRequest(http.MethodGet, "/", nil) + denied.RemoteAddr = "198.51.100.10:1234" + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, denied) + if rec.Code != http.StatusForbidden { + t.Fatalf("denied status = %d, want 403", rec.Code) + } +} + +func TestResolveRealIPOnlyTrustedProxy(t *testing.T) { + savedHeader, savedTrusted := *realIPHeader, parsedTrustedProxies + defer func() { *realIPHeader, parsedTrustedProxies = savedHeader, savedTrusted }() + *realIPHeader = "X-Real-IP" + parsedTrustedProxies, _ = parseIPNetworks("127.0.0.1", "trusted-proxies") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "127.0.0.1:80" + req.Header.Set("X-Real-IP", "203.0.113.7") + if got := resolveRealIP(req); got != "203.0.113.7" { + t.Fatalf("trusted real IP = %q", got) + } + + req.RemoteAddr = "198.51.100.1:80" + if got := resolveRealIP(req); got != req.RemoteAddr { + t.Fatalf("untrusted real IP = %q, want %q", got, req.RemoteAddr) + } +} diff --git a/scripts/generate-update-key.sh b/scripts/generate-update-key.sh new file mode 100755 index 00000000..e9510e3d --- /dev/null +++ b/scripts/generate-update-key.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT INT TERM + +openssl genpkey -algorithm ED25519 -out "$tmp/private.pem" +openssl pkey -in "$tmp/private.pem" -pubout -outform DER -out "$tmp/public.der" + +echo "GitHub Actions secret UPDATE_SIGNING_PRIVATE_KEY:" +cat "$tmp/private.pem" +echo +echo "GitHub Actions variable UPDATE_MANIFEST_PUBLIC_KEY:" +tail -c 32 "$tmp/public.der" | base64 | tr -d '\n' +echo diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 00000000..667b6717 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,83 @@ +[CmdletBinding()] +param( + [string] $Repo = $env:WEBHOOK_REPO, + [string] $InstallDir = $env:WEBHOOK_INSTALL_DIR, + [string] $Version = $env:WEBHOOK_VERSION, + [string] $BinaryName = $env:WEBHOOK_BINARY_NAME +) + +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($Repo)) { + $Repo = "xtulnx/webhook" +} +if ([string]::IsNullOrWhiteSpace($InstallDir)) { + $InstallDir = Join-Path $env:LOCALAPPDATA "Programs\webhook" +} +if ([string]::IsNullOrWhiteSpace($Version)) { + $Version = "latest" +} +if ([string]::IsNullOrWhiteSpace($BinaryName)) { + $BinaryName = "webhook.exe" +} +if (-not $BinaryName.EndsWith(".exe", [StringComparison]::OrdinalIgnoreCase)) { + $BinaryName = "$BinaryName.exe" +} + +switch ($env:PROCESSOR_ARCHITECTURE) { + "AMD64" { $Arch = "amd64" } + "ARM64" { $Arch = "arm64" } + "x86" { $Arch = "386" } + default { + throw "Unsupported architecture: $env:PROCESSOR_ARCHITECTURE" + } +} + +$Asset = "webhook-windows-$Arch.tar.gz" +if ($Version -eq "latest") { + $Url = "https://github.com/$Repo/releases/latest/download/$Asset" + $ChecksumsUrl = "https://github.com/$Repo/releases/latest/download/checksums.txt" +} else { + $Url = "https://github.com/$Repo/releases/download/$Version/$Asset" + $ChecksumsUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" +} + +$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString()) +New-Item -ItemType Directory -Path $TempDir | Out-Null + +try { + $Archive = Join-Path $TempDir $Asset + Write-Host "Downloading $Repo $Version for windows/$Arch..." + Invoke-WebRequest -Uri $Url -OutFile $Archive + $ChecksumsPath = Join-Path $TempDir "checksums.txt" + Invoke-WebRequest -Uri $ChecksumsUrl -OutFile $ChecksumsPath + + $ChecksumLine = Get-Content $ChecksumsPath | Where-Object { $_ -match "\s$([regex]::Escape($Asset))$" } | Select-Object -First 1 + if (-not $ChecksumLine) { + throw "Checksum for $Asset not found" + } + + $ExpectedHash = ($ChecksumLine -split "\s+")[0].ToLowerInvariant() + $ActualHash = (Get-FileHash -Algorithm SHA256 -Path $Archive).Hash.ToLowerInvariant() + if ($ActualHash -ne $ExpectedHash) { + throw "Checksum mismatch for $Asset" + } + + tar -xzf $Archive -C $TempDir + + $Source = Join-Path $TempDir "webhook-windows-$Arch\webhook.exe" + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + Copy-Item -Path $Source -Destination (Join-Path $InstallDir $BinaryName) -Force + + Write-Host "Installed $BinaryName to $InstallDir" + & (Join-Path $InstallDir $BinaryName) -version + + $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") + $Paths = $UserPath -split ";" | Where-Object { $_ } + if ($Paths -notcontains $InstallDir) { + [Environment]::SetEnvironmentVariable("Path", (($Paths + $InstallDir) -join ";"), "User") + Write-Host "Added $InstallDir to the user PATH. Open a new terminal to use webhook directly." + } +} finally { + Remove-Item -Path $TempDir -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000..3b7310a1 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,121 @@ +#!/bin/sh +set -eu + +repo="${WEBHOOK_REPO:-xtulnx/webhook}" +install_dir="${WEBHOOK_INSTALL_DIR:-/usr/local/bin}" +version="${WEBHOOK_VERSION:-latest}" +binary_name="${WEBHOOK_BINARY_NAME:-webhook}" + +need() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "error: required command not found: $1" >&2 + exit 1 + fi +} + +detect_os() { + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + case "$os" in + darwin|freebsd|linux|openbsd) printf '%s' "$os" ;; + *) echo "error: unsupported OS: $os" >&2; exit 1 ;; + esac +} + +detect_arch() { + arch="$(uname -m)" + case "$arch" in + x86_64|amd64) printf 'amd64' ;; + aarch64|arm64) printf 'arm64' ;; + i386|i686) printf '386' ;; + armv6l|armv7l|arm) printf 'arm' ;; + *) echo "error: unsupported architecture: $arch" >&2; exit 1 ;; + esac +} + +download() { + url="$1" + output="$2" + + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$output" + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$output" + else + echo "error: curl or wget is required" >&2 + exit 1 + fi +} + +verify_checksum() { + checksums="$1" + archive="$2" + asset="$3" + + expected="$(grep " $asset\$" "$checksums" | awk '{print $1}')" + if [ -z "$expected" ]; then + echo "error: checksum for $asset not found" >&2 + exit 1 + fi + + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$archive" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "$archive" | awk '{print $1}')" + elif command -v sha256 >/dev/null 2>&1; then + actual="$(sha256 -q "$archive")" + else + echo "warning: no SHA256 command found; skipping checksum verification" >&2 + return + fi + + if [ "$actual" != "$expected" ]; then + echo "error: checksum mismatch for $asset" >&2 + exit 1 + fi +} + +install_binary() { + src="$1" + dst="$2" + + mkdir -p "$install_dir" 2>/dev/null || true + if [ -w "$install_dir" ]; then + install -m 0755 "$src" "$dst" + elif command -v sudo >/dev/null 2>&1; then + sudo mkdir -p "$install_dir" + sudo install -m 0755 "$src" "$dst" + else + echo "error: $install_dir is not writable and sudo is not available" >&2 + exit 1 + fi +} + +need uname +need tar + +os="$(detect_os)" +arch="$(detect_arch)" +asset="webhook-$os-$arch.tar.gz" + +if [ "$version" = "latest" ]; then + url="https://github.com/$repo/releases/latest/download/$asset" + checksums_url="https://github.com/$repo/releases/latest/download/checksums.txt" +else + url="https://github.com/$repo/releases/download/$version/$asset" + checksums_url="https://github.com/$repo/releases/download/$version/checksums.txt" +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT INT TERM + +archive="$tmp/$asset" +echo "Downloading $repo $version for $os/$arch..." +download "$url" "$archive" +download "$checksums_url" "$tmp/checksums.txt" +verify_checksum "$tmp/checksums.txt" "$archive" "$asset" + +tar -xzf "$archive" -C "$tmp" +install_binary "$tmp/webhook-$os-$arch/webhook" "$install_dir/$binary_name" + +echo "Installed $binary_name to $install_dir" +"$install_dir/$binary_name" -version diff --git a/status.go b/status.go new file mode 100644 index 00000000..d709d98f --- /dev/null +++ b/status.go @@ -0,0 +1,160 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/gorilla/mux" +) + +var serviceStartedAt = time.Now().UTC() + +type serviceStatusHookFile struct { + Source string `json:"source"` + LoadedAt string `json:"loadedAt,omitempty"` + Hooks []string `json:"hooks"` +} + +type serviceStatusHooks struct { + Count int `json:"count"` + FileCount int `json:"fileCount"` + LastLoadedAt string `json:"lastLoadedAt,omitempty"` + LoadErrors int `json:"loadErrors"` + Files []serviceStatusHookFile `json:"files"` +} + +type serviceStatusWatcher struct { + Enabled bool `json:"enabled"` + DirectoryCount int `json:"directoryCount"` +} + +type serviceStatusResponse struct { + Status string `json:"status"` + Version string `json:"version"` + StartedAt string `json:"startedAt"` + UptimeSeconds int64 `json:"uptimeSeconds"` + Hooks serviceStatusHooks `json:"hooks"` + Watcher serviceStatusWatcher `json:"watcher"` +} + +func initStatus() error { + path := strings.Trim(strings.TrimSpace(*statusURLPrefix), "/") + if path == "" { + return errors.New("status-path must not be empty") + } + if strings.ContainsAny(path, "{}?#\\") { + return errors.New("status-path contains unsupported URL characters") + } + for _, segment := range strings.Split(path, "/") { + if segment == "" || segment == "." || segment == ".." { + return errors.New("status-path contains an invalid URL segment") + } + } + + hooksPath := strings.Trim(strings.TrimSpace(*hooksURLPrefix), "/") + if path == hooksPath || strings.HasPrefix(path, hooksPath+"/") { + return errors.New("status-path must not be inside urlprefix") + } + if *adminEnabled { + adminPath := strings.Trim(strings.TrimSpace(*adminURLPrefix), "/") + if path == adminPath || strings.HasPrefix(path, adminPath+"/") || strings.HasPrefix(adminPath, path+"/") { + return errors.New("status-path must not overlap admin-path") + } + } + + *statusURLPrefix = path + return nil +} + +func registerStatusRoute(router *mux.Router) { + router.HandleFunc(makeBaseURL(statusURLPrefix), statusHandler).Methods(http.MethodGet, http.MethodHead) +} + +func statusHandler(w http.ResponseWriter, r *http.Request) { + now := time.Now().UTC() + response := serviceStatusSnapshot(now) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + + encoder := json.NewEncoder(w) + encoder.SetEscapeHTML(true) + if err := encoder.Encode(response); err != nil { + http.Error(w, "failed to encode service status", http.StatusInternalServerError) + } +} + +func serviceStatusSnapshot(now time.Time) serviceStatusResponse { + loadedHooksMu.RLock() + files := append([]string(nil), hooksFiles...) + loaded := cloneLoadedHooksMapLocked() + metadata := make(map[string]hookLoadMetadata, len(hookLoadMetadataByFile)) + for path, value := range hookLoadMetadataByFile { + metadata[path] = value + } + failureCount := len(hookLoadFailures) + lastLoaded := lastHooksLoadTime + loadedHooksMu.RUnlock() + + sort.Slice(files, func(i, j int) bool { + left, right := filepath.Base(files[i]), filepath.Base(files[j]) + if left == right { + return files[i] < files[j] + } + return left < right + }) + + hookFiles := make([]serviceStatusHookFile, 0, len(files)) + hookCount := 0 + for _, path := range files { + ids := make([]string, 0, len(loaded[path])) + for _, currentHook := range loaded[path] { + ids = append(ids, currentHook.ID) + } + sort.Strings(ids) + hookCount += len(ids) + + fileStatus := serviceStatusHookFile{ + Source: filepath.Base(path), + Hooks: ids, + } + if value, exists := metadata[path]; exists && !value.LoadedAt.IsZero() { + fileStatus.LoadedAt = value.LoadedAt.Format(time.RFC3339Nano) + } + hookFiles = append(hookFiles, fileStatus) + } + + status := "ok" + if hookCount == 0 || failureCount != 0 { + status = "degraded" + } + result := serviceStatusResponse{ + Status: status, + Version: version, + StartedAt: serviceStartedAt.Format(time.RFC3339Nano), + UptimeSeconds: int64(now.Sub(serviceStartedAt).Seconds()), + Hooks: serviceStatusHooks{ + Count: hookCount, + FileCount: len(hookFiles), + LoadErrors: failureCount, + Files: hookFiles, + }, + Watcher: serviceStatusWatcher{ + Enabled: watcher != nil, + DirectoryCount: len(hooksDirectories), + }, + } + if !lastLoaded.IsZero() { + result.Hooks.LastLoadedAt = lastLoaded.Format(time.RFC3339Nano) + } + return result +} diff --git a/test/hookecho.go b/test/hookecho.go index 6e5e9f7b..d8aee108 100644 --- a/test/hookecho.go +++ b/test/hookecho.go @@ -5,8 +5,10 @@ package main import ( "fmt" "os" + "sort" "strconv" "strings" + "time" ) func main() { @@ -20,18 +22,46 @@ func main() { env = append(env, v) } } + sort.Strings(env) if len(env) > 0 { fmt.Printf("env: %s\n", strings.Join(env, " ")) } - if (len(os.Args) > 1) && (strings.HasPrefix(os.Args[1], "exit=")) { - exit_code_str := os.Args[1][5:] - exit_code, err := strconv.Atoi(exit_code_str) - if err != nil { - fmt.Printf("Exit code %s not an int!", exit_code_str) - os.Exit(-1) + for _, arg := range os.Args[1:] { + switch { + case strings.HasPrefix(arg, "cat-env-file="): + key := strings.TrimPrefix(arg, "cat-env-file=") + path := os.Getenv(key) + if path == "" { + fmt.Printf("File env %s is not set!", key) + os.Exit(-1) + } + content, err := os.ReadFile(path) + if err != nil { + fmt.Printf("Failed to read %s (%s): %v", key, path, err) + os.Exit(-1) + } + fmt.Printf("file: %s=%s\n", key, string(content)) + + case strings.HasPrefix(arg, "sleep="): + sleepFor := strings.TrimPrefix(arg, "sleep=") + duration, err := time.ParseDuration(sleepFor) + if err != nil { + fmt.Printf("Sleep duration %s is invalid!", sleepFor) + os.Exit(-1) + } + time.Sleep(duration) + fmt.Printf("slept: %s\n", duration) + + case strings.HasPrefix(arg, "exit="): + exit_code_str := arg[5:] + exit_code, err := strconv.Atoi(exit_code_str) + if err != nil { + fmt.Printf("Exit code %s not an int!", exit_code_str) + os.Exit(-1) + } + os.Exit(exit_code) } - os.Exit(exit_code) } } diff --git a/test/hooks.json.tmpl b/test/hooks.json.tmpl index 9cfe348f..85fb4558 100644 --- a/test/hooks.json.tmpl +++ b/test/hooks.json.tmpl @@ -531,6 +531,103 @@ ] } }, + { + "id": "keep-file-environment", + "execute-command": "{{ .Hookecho }}", + "include-command-output-in-response": true, + "keep-file-environment": true, + "pass-arguments-to-command": [ + { + "source": "string", + "name": "cat-env-file=HOOK_FILE_PKG" + } + ] + }, + { + "id": "keep-file-environment-special-name", + "execute-command": "{{ .Hookecho }}", + "include-command-output-in-response": true, + "keep-file-environment": true, + "pass-arguments-to-command": [ + { + "source": "string", + "name": "cat-env-file=HOOK_FILE_PKG-NAME" + } + ] + }, + { + "id": "command-timeout-default", + "execute-command": "{{ .Hookecho }}", + "include-command-output-in-response": true, + "include-command-output-in-response-on-error": true, + "pass-arguments-to-command": [ + { + "source": "string", + "name": "sleep=250ms" + } + ] + }, + { + "id": "command-timeout-hook", + "execute-command": "{{ .Hookecho }}", + "command-timeout": "100ms", + "include-command-output-in-response": true, + "include-command-output-in-response-on-error": true, + "pass-arguments-to-command": [ + { + "source": "string", + "name": "sleep=250ms" + } + ] + }, + { + "id": "command-timeout-unlimited", + "execute-command": "{{ .Hookecho }}", + "command-timeout": "0", + "include-command-output-in-response": true, + "include-command-output-in-response-on-error": true, + "pass-arguments-to-command": [ + { + "source": "string", + "name": "sleep=250ms" + } + ] + }, + { + "id": "max-concurrency-default", + "execute-command": "{{ .Hookecho }}", + "include-command-output-in-response": true, + "pass-arguments-to-command": [ + { + "source": "string", + "name": "sleep=250ms" + } + ] + }, + { + "id": "max-concurrency-hook", + "execute-command": "{{ .Hookecho }}", + "max-concurrency": 1, + "include-command-output-in-response": true, + "pass-arguments-to-command": [ + { + "source": "string", + "name": "sleep=250ms" + } + ] + }, + { + "id": "max-concurrency-unlimited", + "execute-command": "{{ .Hookecho }}", + "max-concurrency": 0, + "include-command-output-in-response": true, + "pass-arguments-to-command": [ + { + "source": "string", + "name": "sleep=250ms" + } + ] + }, { "id": "empty-payload-signature", "execute-command": "{{ .Hookecho }}", diff --git a/test/hooks.yaml.tmpl b/test/hooks.yaml.tmpl index b18c1914..f105bce1 100644 --- a/test/hooks.yaml.tmpl +++ b/test/hooks.yaml.tmpl @@ -302,6 +302,71 @@ type: value value: 1 +- id: keep-file-environment + execute-command: '{{ .Hookecho }}' + include-command-output-in-response: true + keep-file-environment: true + pass-arguments-to-command: + - source: string + name: cat-env-file=HOOK_FILE_PKG + +- id: keep-file-environment-special-name + execute-command: '{{ .Hookecho }}' + include-command-output-in-response: true + keep-file-environment: true + pass-arguments-to-command: + - source: string + name: cat-env-file=HOOK_FILE_PKG-NAME + +- id: command-timeout-default + execute-command: '{{ .Hookecho }}' + include-command-output-in-response: true + include-command-output-in-response-on-error: true + pass-arguments-to-command: + - source: string + name: sleep=250ms + +- id: command-timeout-hook + execute-command: '{{ .Hookecho }}' + command-timeout: 100ms + include-command-output-in-response: true + include-command-output-in-response-on-error: true + pass-arguments-to-command: + - source: string + name: sleep=250ms + +- id: command-timeout-unlimited + execute-command: '{{ .Hookecho }}' + command-timeout: '0' + include-command-output-in-response: true + include-command-output-in-response-on-error: true + pass-arguments-to-command: + - source: string + name: sleep=250ms + +- id: max-concurrency-default + execute-command: '{{ .Hookecho }}' + include-command-output-in-response: true + pass-arguments-to-command: + - source: string + name: sleep=250ms + +- id: max-concurrency-hook + execute-command: '{{ .Hookecho }}' + max-concurrency: 1 + include-command-output-in-response: true + pass-arguments-to-command: + - source: string + name: sleep=250ms + +- id: max-concurrency-unlimited + execute-command: '{{ .Hookecho }}' + max-concurrency: 0 + include-command-output-in-response: true + pass-arguments-to-command: + - source: string + name: sleep=250ms + - id: empty-payload-signature include-command-output-in-response: true execute-command: '{{ .Hookecho }}' diff --git a/update_command.go b/update_command.go new file mode 100644 index 00000000..24920151 --- /dev/null +++ b/update_command.go @@ -0,0 +1,199 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + webhookupdate "github.com/adnanh/webhook/internal/update" +) + +var ( + updateEnabled = flag.Bool("update-enabled", true, "enable update checks in the admin API") + updateRepository = flag.String("update-repository", webhookupdate.DefaultRepository, "GitHub repository used for updates") + updateStateDir = flag.String("update-state-dir", "", "directory for update state; defaults to the executable directory") + + updateManifestPublicKey string +) + +func isUpdateCommand(args []string) bool { + return len(args) > 1 && args[1] == "update" +} + +func newUpdateClient(repository string) *webhookupdate.Client { + return &webhookupdate.Client{ + Repository: repository, + Version: version, + PublicKey: updateManifestPublicKey, + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +func runUpdateCommand(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + if len(args) == 0 { + writeUpdateUsage(stderr) + return 2 + } + if args[0] == "-h" || args[0] == "--help" { + writeUpdateUsage(stdout) + return 0 + } + + switch args[0] { + case "check": + return runUpdateCheck(args[1:], stdout, stderr) + case "apply": + return runUpdateApply(args[1:], stdin, stdout, stderr) + case "rollback": + return runUpdateRollback(args[1:], stdout, stderr) + case "help": + writeUpdateUsage(stdout) + return 0 + default: + fmt.Fprintf(stderr, "unknown update command %q\n", args[0]) + writeUpdateUsage(stderr) + return 2 + } +} + +func runUpdateCheck(args []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("webhook update check", flag.ContinueOnError) + flags.SetOutput(stderr) + repository := flags.String("repository", webhookupdate.DefaultRepository, "GitHub repository") + requestedVersion := flags.String("version", "", "release version; defaults to latest") + jsonOutput := flags.Bool("json", false, "write JSON output") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 0 { + fmt.Fprintln(stderr, "update check does not accept positional arguments") + return 2 + } + + result, err := newUpdateClient(*repository).Check(context.Background(), *requestedVersion) + if err != nil { + fmt.Fprintln(stderr, "update check failed:", err) + return 1 + } + if *jsonOutput { + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(result); err != nil { + fmt.Fprintln(stderr, "encode update result:", err) + return 1 + } + return 0 + } + + fmt.Fprintf(stdout, "Current version: %s\n", result.CurrentVersion) + fmt.Fprintf(stdout, "Latest version: %s\n", result.LatestVersion) + if result.Available { + fmt.Fprintln(stdout, "Update available: yes") + } else { + fmt.Fprintln(stdout, "Update available: no") + } + if result.Verified { + fmt.Fprintln(stdout, "Manifest signature: verified") + } else { + fmt.Fprintln(stdout, "Manifest signature: unavailable in this build") + } + return 0 +} + +func runUpdateApply(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("webhook update apply", flag.ContinueOnError) + flags.SetOutput(stderr) + repository := flags.String("repository", webhookupdate.DefaultRepository, "GitHub repository") + requestedVersion := flags.String("version", "", "release version; defaults to latest") + target := flags.String("target", "", "executable to replace; defaults to the current executable") + stateDir := flags.String("state-dir", "", "update state directory; defaults to the current working directory") + yes := flags.Bool("yes", false, "apply without interactive confirmation") + allowUnsigned := flags.Bool("allow-unsigned", false, "allow applying a manifest without a configured signature key") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 0 { + fmt.Fprintln(stderr, "update apply does not accept positional arguments") + return 2 + } + + client := newUpdateClient(*repository) + result, err := client.Check(context.Background(), *requestedVersion) + if err != nil { + fmt.Fprintln(stderr, "update check failed:", err) + return 1 + } + if !result.Verified && !*allowUnsigned { + fmt.Fprintln(stderr, "update cannot be applied because this build has no trusted manifest public key") + return 1 + } + if *requestedVersion == "" && !result.Available { + fmt.Fprintf(stdout, "webhook %s is already up to date.\n", version) + return 0 + } + if !*yes { + fmt.Fprintf(stdout, "Replace webhook %s with %s? [y/N] ", version, result.LatestVersion) + answer, _ := bufio.NewReader(stdin).ReadString('\n') + answer = strings.ToLower(strings.TrimSpace(answer)) + if answer != "y" && answer != "yes" { + fmt.Fprintln(stdout, "Update cancelled.") + return 0 + } + } + + state, err := client.Apply(context.Background(), webhookupdate.ApplyOptions{ + Version: result.LatestVersion, + Target: *target, + StateDir: *stateDir, + AllowUnsigned: *allowUnsigned, + }) + if err != nil { + fmt.Fprintln(stderr, "update failed:", err) + return 1 + } + fmt.Fprintf(stdout, "Updated webhook from %s to %s.\n", state.CurrentVersion, state.InstalledVersion) + fmt.Fprintln(stdout, "Restart the running webhook service to activate the new version.") + return 0 +} + +func runUpdateRollback(args []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("webhook update rollback", flag.ContinueOnError) + flags.SetOutput(stderr) + target := flags.String("target", "", "executable to restore; defaults to the current executable") + stateDir := flags.String("state-dir", "", "update state directory; defaults to the current working directory") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 0 { + fmt.Fprintln(stderr, "update rollback does not accept positional arguments") + return 2 + } + + state, err := webhookupdate.Rollback(*target, *stateDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + fmt.Fprintln(stderr, "rollback is unavailable: no previous update state was found") + } else { + fmt.Fprintln(stderr, "rollback failed:", err) + } + return 1 + } + fmt.Fprintf(stdout, "Restored webhook %s over %s.\n", state.CurrentVersion, state.InstalledVersion) + fmt.Fprintln(stdout, "Restart the running webhook service to activate the restored version.") + return 0 +} + +func writeUpdateUsage(output io.Writer) { + fmt.Fprintln(output, "Usage:") + fmt.Fprintln(output, " webhook update check [--repository owner/name] [--version vX.Y.Z] [--json]") + fmt.Fprintln(output, " webhook update apply [--repository owner/name] [--version vX.Y.Z] [--state-dir path] [--yes] [--allow-unsigned]") + fmt.Fprintln(output, " webhook update rollback [--state-dir path]") +} diff --git a/webhook.go b/webhook.go index ee49251c..5f614540 100644 --- a/webhook.go +++ b/webhook.go @@ -5,6 +5,7 @@ import ( "encoding/json" "flag" "fmt" + "io" "io/ioutil" "log" "net" @@ -13,18 +14,16 @@ import ( "os/exec" "path/filepath" "strings" - "time" "github.com/adnanh/webhook/internal/hook" "github.com/adnanh/webhook/internal/middleware" "github.com/adnanh/webhook/internal/pidfile" - "github.com/fsnotify/fsnotify" chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/gorilla/mux" ) -const ( +var ( version = "2.8.3" ) @@ -36,6 +35,7 @@ var ( debug = flag.Bool("debug", false, "show debug output") noPanic = flag.Bool("nopanic", false, "do not panic if hooks cannot be loaded when webhook is not running in verbose mode") hotReload = flag.Bool("hotreload", false, "watch hooks file for changes and reload them automatically") + statusURLPrefix = flag.String("status-path", "status", "URL path for the service status endpoint") hooksURLPrefix = flag.String("urlprefix", "hooks", "url prefix to use for served hooks (protocol://yourserver:port/PREFIX/:hook-id)") secure = flag.Bool("secure", false, "use HTTPS instead of HTTP") asTemplate = flag.Bool("template", false, "parse hooks file as a Go template") @@ -50,13 +50,18 @@ var ( maxMultipartMem = flag.Int64("max-multipart-mem", 1<<20, "maximum memory in bytes for parsing multipart form data before disk caching") httpMethods = flag.String("http-methods", "", `set default allowed HTTP methods (ie. "POST"); separate methods with comma`) pidPath = flag.String("pidfile", "", "create PID file at the given path") + realIPHeader = flag.String("real-ip-header", "", "header to extract real client IP from when behind a reverse proxy (e.g. X-Real-Ip)") + trustedProxies = flag.String("trusted-proxies", "", "comma-separated list of trusted proxy IPs or CIDRs; required for real-ip-header to take effect") + accessWhitelist = flag.String("access-whitelist", "", "comma-separated list of client IPs or CIDRs allowed to access the service") + accessBlacklist = flag.String("access-blacklist", "", "comma-separated list of client IPs or CIDRs denied from accessing the service") + maxBodySize = flag.Int64("max-body-size", 10<<20, "maximum webhook request body size in bytes (0 disables the limit)") - responseHeaders hook.ResponseHeaders - hooksFiles hook.HooksFiles + responseHeaders hook.ResponseHeaders + hooksFiles hook.HooksFiles + hooksDirectories hook.HooksFiles loadedHooksFromFiles = make(map[string]hook.Hooks) - watcher *fsnotify.Watcher signals chan os.Signal pidFile *pidfile.PIDFile setUID = 0 @@ -66,9 +71,13 @@ var ( ) func matchLoadedHook(id string) *hook.Hook { - for _, hooks := range loadedHooksFromFiles { - if hook := hooks.Match(id); hook != nil { - return hook + loadedHooksMu.RLock() + defer loadedHooksMu.RUnlock() + + for _, hooksInFile := range loadedHooksFromFiles { + if matched := hooksInFile.Match(id); matched != nil { + cloned := cloneHook(*matched) + return &cloned } } @@ -76,23 +85,58 @@ func matchLoadedHook(id string) *hook.Hook { } func lenLoadedHooks() int { + loadedHooksMu.RLock() + defer loadedHooksMu.RUnlock() + sum := 0 - for _, hooks := range loadedHooksFromFiles { - sum += len(hooks) + for _, hooksInFile := range loadedHooksFromFiles { + sum += len(hooksInFile) } return sum } func main() { + if isUpdateCommand(os.Args) { + os.Exit(runUpdateCommand(os.Args[2:], os.Stdin, os.Stdout, os.Stderr)) + } + flag.Var(&hooksFiles, "hooks", "path to the json file containing defined hooks the webhook should serve, use multiple times to load from different files") + flag.Var(&hooksDirectories, "hooks-dir", "directory containing JSON or YAML hooks files; use multiple times to load and watch multiple directories") flag.Var(&responseHeaders, "header", "response header to return, specified in format name=value, use multiple times to set multiple headers") // register platform-specific flags platformFlags() + // Extract -config / -c from os.Args before flag.Parse() sees it. + configFile = extractConfigFlag() + if configFile != "" { + if err := loadConfigFile(configFile); err != nil { + fmt.Println("error:", err) + os.Exit(1) + } + } + flag.Parse() + if err := initTrustedProxies(); err != nil { + fmt.Println("error:", err) + os.Exit(1) + } + if err := initAccessControl(); err != nil { + fmt.Println("error:", err) + os.Exit(1) + } + if *maxBodySize < 0 { + fmt.Println("error: max-body-size must be zero or greater") + os.Exit(1) + } + + if err := initExecutionSettings(); err != nil { + fmt.Println("error:", err) + os.Exit(1) + } + if *justDisplayVersion { fmt.Println("webhook version " + version) os.Exit(0) @@ -116,10 +160,19 @@ func main() { *verbose = true } - if len(hooksFiles) == 0 { + if len(hooksFiles) == 0 && len(hooksDirectories) == 0 { hooksFiles = append(hooksFiles, "hooks.json") } + if err := initAdmin(); err != nil { + fmt.Println("error:", err) + os.Exit(1) + } + if err := initStatus(); err != nil { + fmt.Println("error:", err) + os.Exit(1) + } + // logQueue is a queue for log messages encountered during startup. We need // to queue the messages so that we can handle any privilege dropping and // log file opening prior to writing our first log message. @@ -151,12 +204,17 @@ func main() { } if *logPath != "" { - file, err := os.OpenFile(*logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) + file, err := os.OpenFile(*logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { logQueue = append(logQueue, fmt.Sprintf("error opening log file %q: %v", *logPath, err)) // we'll bail out below } else { - log.SetOutput(file) + if err := file.Chmod(0o600); err != nil { + logQueue = append(logQueue, fmt.Sprintf("error securing log file %q: %v", *logPath, err)) + _ = file.Close() + } else { + log.SetOutput(file) + } } } @@ -198,64 +256,20 @@ func main() { // set os signal watcher setupSignals() - // load and parse hooks - for _, hooksFilePath := range hooksFiles { - log.Printf("attempting to load hooks from %s\n", hooksFilePath) - - newHooks := hook.Hooks{} - - err := newHooks.LoadFromFile(hooksFilePath, *asTemplate) - - if err != nil { - log.Printf("couldn't load hooks from file! %+v\n", err) - } else { - log.Printf("found %d hook(s) in file\n", len(newHooks)) - - for _, hook := range newHooks { - if matchLoadedHook(hook.ID) != nil { - log.Fatalf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!\n", hook.ID) - } - log.Printf("\tloaded: %s\n", hook.ID) - } - - loadedHooksFromFiles[hooksFilePath] = newHooks - } + if err := initializeHookSources(); err != nil { + log.Fatalf("error initializing hooks sources: %v", err) } - newHooksFiles := hooksFiles[:0] - for _, filePath := range hooksFiles { - if _, ok := loadedHooksFromFiles[filePath]; ok { - newHooksFiles = append(newHooksFiles, filePath) - } - } - - hooksFiles = newHooksFiles - - if !*verbose && !*noPanic && lenLoadedHooks() == 0 { + if !*verbose && !*noPanic && len(hooksDirectories) == 0 && lenLoadedHooks() == 0 { log.SetOutput(os.Stdout) log.Fatalln("couldn't load any hooks from file!\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to start without the hooks, either use -verbose flag, or -nopanic") } - if *hotReload { - var err error - - watcher, err = fsnotify.NewWatcher() - if err != nil { - log.Fatal("error creating file watcher instance\n", err) + if *hotReload || len(hooksDirectories) != 0 { + if err := startHookWatcher(); err != nil { + log.Fatal("error creating hooks watcher: ", err) } defer watcher.Close() - - for _, hooksFilePath := range hooksFiles { - // set up file watcher - log.Printf("setting up file watcher for %s\n", hooksFilePath) - - err = watcher.Add(hooksFilePath) - if err != nil { - log.Print("error adding hooks file to the watcher\n", err) - return - } - } - go watchForFileChange() } @@ -267,6 +281,7 @@ func main() { )) r.Use(middleware.NewLogger()) r.Use(chimiddleware.Recoverer) + r.Use(accessControlMiddleware) if *debug { r.Use(middleware.Dumper(log.Writer())) @@ -285,6 +300,8 @@ func main() { fmt.Fprint(w, "OK") }) + registerAdminRoutes(r) + registerStatusRoute(r) r.HandleFunc(hooksURL, hookHandler) // Create common HTTP server settings @@ -295,6 +312,9 @@ func main() { // Serve HTTP if !*secure { log.Printf("serving hooks on http://%s%s", addr, makeHumanPattern(hooksURLPrefix)) + if *adminEnabled { + log.Printf("serving admin on http://%s%s", addr, currentAdminAuth.basePath) + } log.Print(svr.Serve(ln)) return @@ -310,16 +330,24 @@ func main() { svr.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) // disable http/2 log.Printf("serving hooks on https://%s%s", addr, makeHumanPattern(hooksURLPrefix)) + if *adminEnabled { + log.Printf("serving admin on https://%s%s", addr, currentAdminAuth.basePath) + } log.Print(svr.ServeTLS(ln, *cert, *key)) } func hookHandler(w http.ResponseWriter, r *http.Request) { + if *maxBodySize > 0 { + r.Body = http.MaxBytesReader(w, r.Body, *maxBodySize) + } + req := &hook.Request{ ID: middleware.GetReqID(r.Context()), RawRequest: r, + RealIP: resolveRealIP(r), } - log.Printf("[%s] incoming HTTP %s request from %s\n", req.ID, r.Method, r.RemoteAddr) + log.Printf("[%s] incoming HTTP %s request from %s\n", req.ID, r.Method, req.RealIP) // TODO: rename this to avoid confusion with Request.ID id := mux.Vars(r)["id"] @@ -382,6 +410,11 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { req.Body, err = ioutil.ReadAll(r.Body) if err != nil { log.Printf("[%s] error reading the request body: %+v\n", req.ID, err) + if *maxBodySize > 0 { + w.WriteHeader(http.StatusRequestEntityTooLarge) + fmt.Fprint(w, "Request body too large.") + return + } } } @@ -412,6 +445,11 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { if err != nil { msg := fmt.Sprintf("[%s] error parsing multipart form: %+v\n", req.ID, err) log.Println(msg) + if _, ok := err.(*http.MaxBytesError); ok { + w.WriteHeader(http.StatusRequestEntityTooLarge) + fmt.Fprint(w, "Request body too large.") + return + } w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(w, "Error occurred while parsing multipart form.") return @@ -473,6 +511,9 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("[%s] error parsing JSON payload file: %+v\n", req.ID, err) } + if err := f.Close(); err != nil { + log.Printf("[%s] error closing multipart form file: %+v\n", req.ID, err) + } if req.Payload == nil { req.Payload = make(map[string]interface{}) @@ -524,12 +565,14 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { response, err := handleHook(matchedHook, req) if err != nil { - w.WriteHeader(http.StatusInternalServerError) - if matchedHook.CaptureCommandOutputOnError { + status, message := hookExecutionErrorStatus(err) + if matchedHook.CaptureCommandOutputOnError && response != "" { + w.WriteHeader(status) fmt.Fprint(w, response) } else { w.Header().Set("Content-Type", "text/plain; charset=utf-8") - fmt.Fprint(w, "Error occurred while executing the hook's command. Please check your logs for more details.") + w.WriteHeader(status) + fmt.Fprint(w, message) } } else { // Check if a success return code is configured for the hook @@ -539,7 +582,13 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, response) } } else { - go handleHook(matchedHook, req) + if err := handleHookAsync(matchedHook, req); err != nil { + status, message := hookExecutionErrorStatus(err) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(status) + fmt.Fprint(w, message) + return + } // Check if a success return code is configured for the hook if matchedHook.SuccessHttpResponseCode != 0 { @@ -562,7 +611,9 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "Hook rules were not satisfied.") } -func handleHook(h *hook.Hook, r *hook.Request) (string, error) { +func executeHook(h *hook.Hook, r *hook.Request, waitForCommand bool, release func()) (string, error) { + defer release() + var errors []error // check the command exists @@ -586,7 +637,19 @@ func handleHook(h *hook.Hook, r *hook.Request) (string, error) { return "", err } - cmd := exec.Command(cmdPath) + ctx, cancel, useContext, err := hookExecutionContext(h, r, waitForCommand) + if err != nil { + log.Printf("[%s] error preparing command execution: %s", r.ID, err) + return "", err + } + defer cancel() + + var cmd *exec.Cmd + if useContext { + cmd = exec.CommandContext(ctx, cmdPath) + } else { + cmd = exec.Command(cmdPath) + } cmd.Dir = h.CommandWorkingDirectory cmd.Args, errors = h.ExtractCommandArguments(r) @@ -627,6 +690,52 @@ func handleHook(h *hook.Hook, r *hook.Request) (string, error) { envs = append(envs, files[i].EnvName+"="+tmpfile.Name()) } + if h.KeepFileEnvironment && r.RawRequest != nil && r.RawRequest.MultipartForm != nil { + for k, v := range r.RawRequest.MultipartForm.File { + env_name := hook.EnvNamespace + "FILE_" + strings.ToUpper(k) + f, err := v[0].Open() + if err != nil { + log.Printf("[%s] error open form %s file [%s]", r.ID, k, err) + continue + } + if f1, ok := f.(*os.File); ok { + log.Printf("[%s] temporary file %s", r.ID, f1.Name()) + _ = f1.Close() + files = append(files, hook.FileParameter{File: f1, EnvName: env_name}) + envs = append(envs, + env_name+"="+f1.Name(), + hook.EnvNamespace+"FILENAME_"+strings.ToUpper(k)+"="+v[0].Filename, + ) + continue + } + tmpfile, err := os.CreateTemp("", ".hook-"+r.ID+"-"+k+"-*") + if err != nil { + _ = f.Close() + log.Printf("[%s] error creating temp file [%s]", r.ID, err) + continue + } + log.Printf("[%s] writing env %s file %s", r.ID, env_name, tmpfile.Name()) + if _, err = io.Copy(tmpfile, f); err != nil { + log.Printf("[%s] error writing file %s [%s]", r.ID, tmpfile.Name(), err) + _ = f.Close() + _ = tmpfile.Close() + _ = os.Remove(tmpfile.Name()) + continue + } + if err := tmpfile.Close(); err != nil { + log.Printf("[%s] error closing file %s [%s]", r.ID, tmpfile.Name(), err) + _ = os.Remove(tmpfile.Name()) + continue + } + _ = f.Close() + files = append(files, hook.FileParameter{File: tmpfile, EnvName: env_name}) + envs = append(envs, + env_name+"="+tmpfile.Name(), + hook.EnvNamespace+"FILENAME_"+strings.ToUpper(k)+"="+v[0].Filename, + ) + } + } + cmd.Env = append(os.Environ(), envs...) log.Printf("[%s] executing %s (%s) with arguments %q and environment %s using %s as cwd\n", r.ID, h.ExecuteCommand, cmd.Path, cmd.Args, envs, cmd.Dir) @@ -643,11 +752,16 @@ func handleHook(h *hook.Hook, r *hook.Request) (string, error) { if files[i].File != nil { log.Printf("[%s] removing file %s\n", r.ID, files[i].File.Name()) err := os.Remove(files[i].File.Name()) - if err != nil { + if err != nil && !os.IsNotExist(err) { log.Printf("[%s] error removing file %s [%s]", r.ID, files[i].File.Name(), err) } } } + if r.RawRequest != nil && r.RawRequest.MultipartForm != nil { + if err := r.RawRequest.MultipartForm.RemoveAll(); err != nil && !os.IsNotExist(err) { + log.Printf("[%s] error removing multipart form temp files [%s]", r.ID, err) + } + } log.Printf("[%s] finished handling %s\n", r.ID, h.ID) @@ -664,111 +778,6 @@ func writeHttpResponseCode(w http.ResponseWriter, rid, hookId string, responseCo } } -func reloadHooks(hooksFilePath string) { - hooksInFile := hook.Hooks{} - - // parse and swap - log.Printf("attempting to reload hooks from %s\n", hooksFilePath) - - err := hooksInFile.LoadFromFile(hooksFilePath, *asTemplate) - - if err != nil { - log.Printf("couldn't load hooks from file! %+v\n", err) - } else { - seenHooksIds := make(map[string]bool) - - log.Printf("found %d hook(s) in file\n", len(hooksInFile)) - - for _, hook := range hooksInFile { - wasHookIDAlreadyLoaded := false - - for _, loadedHook := range loadedHooksFromFiles[hooksFilePath] { - if loadedHook.ID == hook.ID { - wasHookIDAlreadyLoaded = true - break - } - } - - if (matchLoadedHook(hook.ID) != nil && !wasHookIDAlreadyLoaded) || seenHooksIds[hook.ID] { - log.Printf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!", hook.ID) - log.Println("reverting hooks back to the previous configuration") - return - } - - seenHooksIds[hook.ID] = true - log.Printf("\tloaded: %s\n", hook.ID) - } - - loadedHooksFromFiles[hooksFilePath] = hooksInFile - } -} - -func reloadAllHooks() { - for _, hooksFilePath := range hooksFiles { - reloadHooks(hooksFilePath) - } -} - -func removeHooks(hooksFilePath string) { - for _, hook := range loadedHooksFromFiles[hooksFilePath] { - log.Printf("\tremoving: %s\n", hook.ID) - } - - newHooksFiles := hooksFiles[:0] - for _, filePath := range hooksFiles { - if filePath != hooksFilePath { - newHooksFiles = append(newHooksFiles, filePath) - } - } - - hooksFiles = newHooksFiles - - removedHooksCount := len(loadedHooksFromFiles[hooksFilePath]) - - delete(loadedHooksFromFiles, hooksFilePath) - - log.Printf("removed %d hook(s) that were loaded from file %s\n", removedHooksCount, hooksFilePath) - - if !*verbose && !*noPanic && lenLoadedHooks() == 0 { - log.SetOutput(os.Stdout) - log.Fatalln("couldn't load any hooks from file!\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to run without the hooks, either use -verbose flag, or -nopanic") - } -} - -func watchForFileChange() { - for { - select { - case event := <-(*watcher).Events: - if event.Op&fsnotify.Write == fsnotify.Write { - log.Printf("hooks file %s modified\n", event.Name) - reloadHooks(event.Name) - } else if event.Op&fsnotify.Remove == fsnotify.Remove { - if _, err := os.Stat(event.Name); os.IsNotExist(err) { - log.Printf("hooks file %s removed, no longer watching this file for changes, removing hooks that were loaded from it\n", event.Name) - (*watcher).Remove(event.Name) - removeHooks(event.Name) - } - } else if event.Op&fsnotify.Rename == fsnotify.Rename { - time.Sleep(100 * time.Millisecond) - if _, err := os.Stat(event.Name); os.IsNotExist(err) { - // file was removed - log.Printf("hooks file %s removed, no longer watching this file for changes, and removing hooks that were loaded from it\n", event.Name) - (*watcher).Remove(event.Name) - removeHooks(event.Name) - } else { - // file was overwritten - log.Printf("hooks file %s overwritten\n", event.Name) - reloadHooks(event.Name) - (*watcher).Remove(event.Name) - (*watcher).Add(event.Name) - } - } - case err := <-(*watcher).Errors: - log.Println("watcher error:", err) - } - } -} - // valuesToMap converts map[string][]string to a map[string]string object func valuesToMap(values map[string][]string) map[string]interface{} { ret := make(map[string]interface{}) diff --git a/webhook.yaml.example b/webhook.yaml.example new file mode 100644 index 00000000..0d8b0740 --- /dev/null +++ b/webhook.yaml.example @@ -0,0 +1,199 @@ +# Webhook 配置文件 +# 用法: webhook -c /etc/webhook/webhook.yaml +# +# 支持所有命令行参数。 +# 列表类参数(hooks、hooks-dir、header)使用 YAML 数组语法。 +# 布尔类参数使用 true/false。 +# 命令行参数优先级高于本配置文件。 + +# ============================================================ +# 网络设置 +# ============================================================ + +# 监听地址,默认 0.0.0.0(监听所有网卡) +ip: 0.0.0.0 + +# 监听端口,默认 9000 +port: 1987 + +# URL 前缀,最终 hook 地址为 http://host:port/PREFIX/hook-id +# 默认值为 "hooks" +urlprefix: wh + +# 限制允许的 HTTP 方法,多个方法用逗号分隔(如 "POST,PUT") +# 留空表示允许所有方法 +# http-methods: "POST" + +# 全局访问控制(二选一;支持 IP 或 CIDR,逗号分隔) +# access-whitelist: "203.0.113.0/24" +# access-blacklist: "198.51.100.10" + +# ============================================================ +# 反向代理 / 真实 IP +# ============================================================ + +# 当 webhook 部署在 Nginx 等反向代理后面时,RemoteAddr 是代理的内网 IP, +# 需要通过以下两个参数配合,让 ip-whitelist 规则能正确识别真实客户端 IP。 + +# 从哪个请求头获取真实客户端 IP +# 常见值: "X-Real-Ip"(Nginx proxy_set_header X-Real-IP) +# "X-Forwarded-For"(取第一个 IP) +# 留空表示不启用,直接使用 RemoteAddr +# real-ip-header: "X-Real-Ip" + +# 可信代理 IP 或 CIDR 列表,逗号分隔 +# 只有当请求来自这些地址时,才会从 real-ip-header 中提取真实 IP +# 防止客户端伪造 header 绕过 IP 白名单 +# 示例: "127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" +# trusted-proxies: "127.0.0.1,10.0.0.0/8" + +# ============================================================ +# Hook 定义文件 +# ============================================================ + +# hook 定义文件路径,支持 JSON 和 YAML 格式 +# 可指定多个文件,每个文件中的 hook id 不能重复 +hooks: + - /etc/webhook/admin.yaml + - /etc/webhook/hooks.yaml + +# hook 定义目录,可重复配置。加载目录直属的 .json/.yaml/.yml 文件并自动监听 +# hooks-dir: +# - /etc/webhook/hooks.d + +# ============================================================ +# 运行行为 +# ============================================================ + +# 当无法加载任何 hook 时不要 panic 退出(需配合非 verbose 模式) +# 默认 false +nopanic: true + +# 监视 hook 文件变化,自动热重载 +# 默认 false +hotreload: true + +# 服务状态接口路径,默认可通过 GET /status 查看运行时间、已加载 hook 和最近加载时间 +# status-path: status + +# Update checks use xtulnx/webhook by default. State files use the current +# working directory unless update-state-dir is set. +update-enabled: true +update-repository: xtulnx/webhook +# update-state-dir: /var/lib/webhook/update + +# 将 hook 文件作为 Go template 解析 +# 默认 false +# template: false + +# multipart 表单解析时的最大内存(字节),超出部分写入磁盘临时文件 +# 默认 1048576 (1MB) +# max-multipart-mem: 1048576 + +# 单个请求体最大字节数,默认 10 MiB;设为 0 表示不限制 +# max-body-size: 10485760 + +# ============================================================ +# 日志设置 +# ============================================================ + +# 启用详细日志输出 +# 默认 false +# verbose: true + +# 启用调试输出(会打印完整的请求/响应内容,隐含 verbose=true) +# 默认 false +# debug: false + +# 日志输出到文件(设置后隐含 verbose=true) +# 留空表示输出到 stdout +# logfile: /var/log/webhook.log + +# ============================================================ +# 自定义响应头 +# ============================================================ + +# 所有 hook 响应都会附带这些 header,格式为 "Name=Value" +# header: +# - "X-Custom-Header=value" +# - "Access-Control-Allow-Origin=*" + +# ============================================================ +# Request ID +# ============================================================ + +# 使用客户端传入的 X-Request-Id 头作为请求 ID(用于日志追踪) +# 默认 false +# x-request-id: false + +# 截断 X-Request-Id 的最大长度,0 表示不限制 +# x-request-id-limit: 0 + +# ============================================================ +# TLS / HTTPS 设置 +# ============================================================ + +# 启用 HTTPS 模式 +# 默认 false +# secure: false + +# TLS 证书文件路径 +# cert: /etc/webhook/cert.pem + +# TLS 私钥文件路径 +# key: /etc/webhook/key.pem + +# TLS 最低版本,可选 "1.0", "1.1", "1.2", "1.3" +# 默认 "1.2" +# tls-min-version: "1.2" + +# TLS 加密套件,逗号分隔。留空使用 Go 默认值 +# 可通过 webhook --list-cipher-suites 查看可用列表 +# cipher-suites: "" + +# ============================================================ +# Admin 管理界面 +# ============================================================ + +# 启用 Admin UI 和 API,用于在线管理 hooks +# 默认 false +# admin: false + +# Admin 界面的 URL 前缀,最终地址为 http://host:port/PREFIX/ +# 默认 "admin" +# admin-path: admin + +# Admin 登录使用的 TOTP 密钥(Base32 编码) +# 可用 Google Authenticator 等 APP 扫码登录 +# admin-totp-secret: "" + +# 用于签发 Admin JWT 会话令牌的密钥 +# admin-jwt-secret: "" + +# Admin JWT 会话有效期,支持 Go duration 格式(如 12h、30m、720h) +# 默认 "12h" +# admin-session-ttl: "12h" + +# ============================================================ +# Unix Socket(仅 Linux/macOS) +# ============================================================ + +# 使用 Unix socket 代替 IP:Port 监听 +# 设置后 ip 和 port 选项将被忽略 +# socket: /tmp/webhook.sock + +# ============================================================ +# 权限降级(仅 Linux/macOS) +# ============================================================ + +# 打开监听端口后切换到指定的用户/组 ID 运行 +# setuid 和 setgid 必须同时设置,不能与 socket 同时使用 +# setuid: 1000 +# setgid: 1000 + +# ============================================================ +# PID 文件 +# ============================================================ + +# 创建 PID 文件,用于进程管理 +# pidfile: /var/run/webhook.pid diff --git a/webhook_test.go b/webhook_test.go index c3f06100..9ec50764 100755 --- a/webhook_test.go +++ b/webhook_test.go @@ -5,6 +5,7 @@ import ( "fmt" "io/ioutil" "log" + "mime/multipart" "net" "net/http" "os" @@ -332,6 +333,120 @@ func killAndWait(cmd *exec.Cmd) { cmd.Wait() } +func startWebhookServer(t *testing.T, webhookBin, configPath string, extraArgs ...string) (*exec.Cmd, *buffer, string) { + t.Helper() + + ip, port := serverAddress(t) + args := []string{ + fmt.Sprintf("-hooks=%s", configPath), + fmt.Sprintf("-ip=%s", ip), + fmt.Sprintf("-port=%s", port), + "-debug", + } + args = append(args, extraArgs...) + + logs := &buffer{} + + cmd := exec.Command(webhookBin, args...) + cmd.Stderr = logs + cmd.Env = webhookEnv() + cmd.Args[0] = "webhook" + if err := cmd.Start(); err != nil { + t.Fatalf("failed to start webhook: %s", err) + } + + waitForServerReady(t, net.JoinHostPort(ip, port), &http.Client{}) + + return cmd, logs, "http://" + net.JoinHostPort(ip, port) +} + +func doJSONHookRequestResult(baseURL, hookID string) (int, string, error) { + req, err := http.NewRequest(http.MethodPost, baseURL+"/hooks/"+hookID, bytes.NewBufferString(`{}`)) + if err != nil { + return 0, "", err + } + req.Header.Set("Content-Type", "application/json") + req.ContentLength = int64(len(`{}`)) + + client := &http.Client{} + res, err := client.Do(req) + if err != nil { + return 0, "", err + } + defer res.Body.Close() + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return 0, "", err + } + + return res.StatusCode, string(body), nil +} + +func doJSONHookRequest(t *testing.T, baseURL, hookID string) (int, string) { + t.Helper() + + status, body, err := doJSONHookRequestResult(baseURL, hookID) + if err != nil { + t.Fatalf("failed to execute request: %v", err) + } + + return status, body +} + +func doMultipartHookRequest(t *testing.T, baseURL, hookID, fieldName, fileName string, data []byte) (int, string) { + t.Helper() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + part, err := writer.CreateFormFile(fieldName, fileName) + if err != nil { + t.Fatalf("failed to create multipart form file: %v", err) + } + if _, err := part.Write(data); err != nil { + t.Fatalf("failed to write multipart payload: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + + req, err := http.NewRequest(http.MethodPost, baseURL+"/hooks/"+hookID, bytes.NewReader(body.Bytes())) + if err != nil { + t.Fatalf("failed to create multipart request: %v", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.ContentLength = int64(body.Len()) + + client := &http.Client{} + res, err := client.Do(req) + if err != nil { + t.Fatalf("failed to execute multipart request: %v", err) + } + defer res.Body.Close() + + respBody, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatalf("failed to read multipart response body: %v", err) + } + + return res.StatusCode, string(respBody) +} + +func waitForBufferContains(t *testing.T, b *buffer, needle string, timeout time.Duration) { + t.Helper() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if strings.Contains(b.String(), needle) { + return + } + time.Sleep(10 * time.Millisecond) + } + + t.Fatalf("buffer did not contain %q within %v; got:\n%s", needle, timeout, b.String()) +} + // webhookEnv returns the process environment without any existing hook // namespace variables. func webhookEnv() (env []string) { @@ -344,6 +459,273 @@ func webhookEnv() (env []string) { return } +func TestWebhookKeepFileEnvironment(t *testing.T) { + hookecho, cleanupHookecho := buildHookecho(t) + defer cleanupHookecho() + + webhookBin, cleanupWebhook := buildWebhook(t) + defer cleanupWebhook() + + tests := []struct { + name string + id string + fieldName string + fileName string + fileContent string + fileEnv string + nameEnv string + }{ + { + name: "default field name", + id: "keep-file-environment", + fieldName: "pkg", + fileName: "pkg.tar.gz", + fileContent: "payload-data", + fileEnv: "HOOK_FILE_PKG", + nameEnv: "HOOK_FILENAME_PKG", + }, + { + name: "special field name", + id: "keep-file-environment-special-name", + fieldName: "pkg-name", + fileName: "pkg-name.txt", + fileContent: "special-data", + fileEnv: "HOOK_FILE_PKG-NAME", + nameEnv: "HOOK_FILENAME_PKG-NAME", + }, + } + + for _, hookTmpl := range []string{"test/hooks.json.tmpl", "test/hooks.yaml.tmpl"} { + configPath, cleanupConfig := genConfig(t, hookecho, hookTmpl) + defer cleanupConfig() + + for _, tt := range tests { + t.Run(tt.name+"@"+hookTmpl, func(t *testing.T) { + cmd, _, baseURL := startWebhookServer(t, webhookBin, configPath) + defer killAndWait(cmd) + + status, body := doMultipartHookRequest(t, baseURL, tt.id, tt.fieldName, tt.fileName, []byte(tt.fileContent)) + if status != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", status, body) + } + + if !strings.Contains(body, "arg: cat-env-file="+tt.fileEnv) { + t.Fatalf("response did not contain file env arg: %s", body) + } + if !strings.Contains(body, tt.nameEnv+"="+tt.fileName) { + t.Fatalf("response did not contain filename env %s=%s: %s", tt.nameEnv, tt.fileName, body) + } + if !strings.Contains(body, "file: "+tt.fileEnv+"="+tt.fileContent) { + t.Fatalf("response did not contain file contents for %s: %s", tt.fileEnv, body) + } + + match := regexp.MustCompile(`(?m)^env: .*` + regexp.QuoteMeta(tt.fileEnv) + `=([^ ]+)`).FindStringSubmatch(body) + if len(match) != 2 { + t.Fatalf("could not extract temp file path from response: %s", body) + } + + if _, err := os.Stat(match[1]); !os.IsNotExist(err) { + t.Fatalf("expected temp file %s to be removed after execution, stat err=%v", match[1], err) + } + }) + } + } +} + +func TestWebhookCommandTimeout(t *testing.T) { + hookecho, cleanupHookecho := buildHookecho(t) + defer cleanupHookecho() + + webhookBin, cleanupWebhook := buildWebhook(t) + defer cleanupWebhook() + + tests := []struct { + name string + id string + extraArgs []string + wantStatus int + wantContains []string + wantNotContains []string + minElapsed time.Duration + maxElapsed time.Duration + }{ + { + name: "global default timeout", + id: "command-timeout-default", + extraArgs: []string{"-command-timeout=100ms"}, + wantStatus: http.StatusInternalServerError, + wantContains: nil, + wantNotContains: []string{"slept:"}, + maxElapsed: 220 * time.Millisecond, + }, + { + name: "hook timeout", + id: "command-timeout-hook", + wantStatus: http.StatusInternalServerError, + wantContains: nil, + wantNotContains: []string{"slept:"}, + maxElapsed: 220 * time.Millisecond, + }, + { + name: "hook timeout override disabled", + id: "command-timeout-unlimited", + extraArgs: []string{"-command-timeout=100ms"}, + wantStatus: http.StatusOK, + wantContains: []string{"arg: sleep=250ms", "slept: 250ms"}, + wantNotContains: nil, + minElapsed: 220 * time.Millisecond, + }, + } + + for _, hookTmpl := range []string{"test/hooks.json.tmpl", "test/hooks.yaml.tmpl"} { + configPath, cleanupConfig := genConfig(t, hookecho, hookTmpl) + defer cleanupConfig() + + for _, tt := range tests { + t.Run(tt.name+"@"+hookTmpl, func(t *testing.T) { + cmd, _, baseURL := startWebhookServer(t, webhookBin, configPath, tt.extraArgs...) + defer killAndWait(cmd) + + start := time.Now() + status, body := doJSONHookRequest(t, baseURL, tt.id) + elapsed := time.Since(start) + + if status != tt.wantStatus { + t.Fatalf("expected status %d, got %d: %s", tt.wantStatus, status, body) + } + for _, want := range tt.wantContains { + if !strings.Contains(body, want) { + t.Fatalf("response missing %q: %s", want, body) + } + } + for _, notWant := range tt.wantNotContains { + if strings.Contains(body, notWant) { + t.Fatalf("response unexpectedly contained %q: %s", notWant, body) + } + } + if tt.minElapsed > 0 && elapsed < tt.minElapsed { + t.Fatalf("request completed too quickly: got %v, want >= %v", elapsed, tt.minElapsed) + } + if tt.maxElapsed > 0 && elapsed > tt.maxElapsed { + t.Fatalf("request completed too slowly: got %v, want <= %v", elapsed, tt.maxElapsed) + } + }) + } + } +} + +func TestWebhookMaxConcurrency(t *testing.T) { + hookecho, cleanupHookecho := buildHookecho(t) + defer cleanupHookecho() + + webhookBin, cleanupWebhook := buildWebhook(t) + defer cleanupWebhook() + + limitedTests := []struct { + name string + id string + extraArgs []string + limitNeedle string + }{ + { + name: "global default limit", + id: "max-concurrency-default", + extraArgs: []string{"-max-concurrency=1"}, + limitNeedle: "Hook concurrency limit exceeded. Please try again later.", + }, + { + name: "hook limit", + id: "max-concurrency-hook", + extraArgs: nil, + limitNeedle: "Hook concurrency limit exceeded. Please try again later.", + }, + } + + for _, hookTmpl := range []string{"test/hooks.json.tmpl", "test/hooks.yaml.tmpl"} { + configPath, cleanupConfig := genConfig(t, hookecho, hookTmpl) + defer cleanupConfig() + + for _, tt := range limitedTests { + t.Run(tt.name+"@"+hookTmpl, func(t *testing.T) { + cmd, logs, baseURL := startWebhookServer(t, webhookBin, configPath, tt.extraArgs...) + defer killAndWait(cmd) + + type result struct { + status int + body string + err error + } + + firstDone := make(chan result, 1) + go func() { + status, body, err := doJSONHookRequestResult(baseURL, tt.id) + firstDone <- result{status: status, body: body, err: err} + }() + + waitForBufferContains(t, logs, "executing", time.Second) + + secondStatus, secondBody := doJSONHookRequest(t, baseURL, tt.id) + firstResult := <-firstDone + if firstResult.err != nil { + t.Fatalf("first request failed: %v", firstResult.err) + } + + if secondStatus != http.StatusServiceUnavailable { + t.Fatalf("expected second request to return 503, got %d: %s", secondStatus, secondBody) + } + if !strings.Contains(secondBody, tt.limitNeedle) { + t.Fatalf("expected second response to contain %q: %s", tt.limitNeedle, secondBody) + } + if firstResult.status != http.StatusOK { + t.Fatalf("expected first request to succeed, got %d: %s", firstResult.status, firstResult.body) + } + if !strings.Contains(firstResult.body, "slept: 250ms") { + t.Fatalf("expected first response to contain sleep output: %s", firstResult.body) + } + }) + } + + t.Run("hook limit override disabled@"+hookTmpl, func(t *testing.T) { + cmd, _, baseURL := startWebhookServer(t, webhookBin, configPath, "-max-concurrency=1") + defer killAndWait(cmd) + + type result struct { + status int + body string + err error + } + + results := make(chan result, 2) + start := time.Now() + for i := 0; i < 2; i++ { + go func() { + status, body, err := doJSONHookRequestResult(baseURL, "max-concurrency-unlimited") + results <- result{status: status, body: body, err: err} + }() + } + + first := <-results + second := <-results + elapsed := time.Since(start) + + for _, result := range []result{first, second} { + if result.err != nil { + t.Fatalf("request failed: %v", result.err) + } + if result.status != http.StatusOK { + t.Fatalf("expected request to succeed, got %d: %s", result.status, result.body) + } + if !strings.Contains(result.body, "slept: 250ms") { + t.Fatalf("expected response to contain sleep output: %s", result.body) + } + } + if elapsed > 450*time.Millisecond { + t.Fatalf("expected unlimited override to allow parallel execution, took %v", elapsed) + } + }) + } +} + type hookHandlerTest struct { desc string id string