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..f4153a1f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,140 @@ +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 }}" \ + -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: 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 + 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..a15d32f5 --- /dev/null +++ b/README_CN.md @@ -0,0 +1,485 @@ +# 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 自动编译、打包、校验和发布脚本。 + +## 快速安装或更新 + +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 ./... +``` + +生成本地 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..b0bd1577 --- /dev/null +++ b/admin.go @@ -0,0 +1,479 @@ +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/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..cb0d60ec --- /dev/null +++ b/admin_store.go @@ -0,0 +1,356 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "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 + 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 + 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/adminui/assets/app.js b/adminui/assets/app.js new file mode 100644 index 00000000..710a4384 --- /dev/null +++ b/adminui/assets/app.js @@ -0,0 +1,1275 @@ +(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"), + 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"); + }); + } + + 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.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..488a78a3 --- /dev/null +++ b/adminui/assets/style.css @@ -0,0 +1,608 @@ +: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); +} + +.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..f4e684e7 --- /dev/null +++ b/adminui/index.html @@ -0,0 +1,223 @@ + + + + + + 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..c0276912 100644 --- a/docs/Webhook-Parameters.md +++ b/docs/Webhook-Parameters.md @@ -5,6 +5,8 @@ Usage of webhook: 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 @@ -23,6 +25,8 @@ 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-multipart-mem int maximum memory in bytes for parsing multipart form data before disk caching (default 1048576) -nopanic @@ -57,6 +61,8 @@ 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. + # 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 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/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..f87dce40 100644 --- a/internal/hook/hook.go +++ b/internal/hook/hook.go @@ -567,6 +567,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 +583,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 +844,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 +995,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/realip.go b/realip.go new file mode 100644 index 00000000..bca60e63 --- /dev/null +++ b/realip.go @@ -0,0 +1,90 @@ +package main + +import ( + "net" + "net/http" + "strings" +) + +// parsedTrustedProxies holds the parsed CIDR networks from the --trusted-proxies flag. +var parsedTrustedProxies []*net.IPNet + +// initTrustedProxies parses the --trusted-proxies flag value into CIDR networks. +// Must be called after flag.Parse(). +func initTrustedProxies() { + if *trustedProxies == "" { + return + } + + for _, entry := range strings.Split(*trustedProxies, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + + // If it's a plain IP without CIDR notation, add /32 (IPv4) or /128 (IPv6). + if !strings.Contains(entry, "/") { + if strings.Contains(entry, ":") { + entry += "/128" + } else { + entry += "/32" + } + } + + _, cidr, err := net.ParseCIDR(entry) + if err != nil { + continue + } + parsedTrustedProxies = append(parsedTrustedProxies, cidr) + } +} + +// 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/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/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/webhook.go b/webhook.go index ee49251c..1945615a 100644 --- a/webhook.go +++ b/webhook.go @@ -5,6 +5,7 @@ import ( "encoding/json" "flag" "fmt" + "io" "io/ioutil" "log" "net" @@ -24,9 +25,7 @@ import ( "github.com/gorilla/mux" ) -const ( - version = "2.8.3" -) +var version = "2.8.3" var ( ip = flag.String("ip", "0.0.0.0", "ip the webhook should serve hooks on") @@ -50,6 +49,8 @@ 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") responseHeaders hook.ResponseHeaders hooksFiles hook.HooksFiles @@ -66,9 +67,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,9 +81,12 @@ 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 @@ -91,8 +99,24 @@ func main() { // 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() + initTrustedProxies() + + if err := initExecutionSettings(); err != nil { + fmt.Println("error:", err) + os.Exit(1) + } + if *justDisplayVersion { fmt.Println("webhook version " + version) os.Exit(0) @@ -120,6 +144,11 @@ func main() { hooksFiles = append(hooksFiles, "hooks.json") } + if err := initAdmin(); 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. @@ -209,27 +238,34 @@ func main() { if err != nil { log.Printf("couldn't load hooks from file! %+v\n", err) } else { + loadedHooksMu.Lock() + candidate := cloneLoadedHooksMapLocked() + candidate[hooksFilePath] = cloneHooks(newHooks) + if err := validateUniqueHookIDs(candidate); err != nil { + loadedHooksMu.Unlock() + log.Fatalf("error: %s\nplease check your hooks files for duplicate hook ids!\n", err) + } + 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) + for _, currentHook := range newHooks { + log.Printf("\tloaded: %s\n", currentHook.ID) } loadedHooksFromFiles[hooksFilePath] = newHooks + loadedHooksMu.Unlock() } } + loadedHooksMu.Lock() newHooksFiles := hooksFiles[:0] for _, filePath := range hooksFiles { if _, ok := loadedHooksFromFiles[filePath]; ok { newHooksFiles = append(newHooksFiles, filePath) } } - hooksFiles = newHooksFiles + loadedHooksMu.Unlock() if !*verbose && !*noPanic && lenLoadedHooks() == 0 { log.SetOutput(os.Stdout) @@ -245,7 +281,7 @@ func main() { } defer watcher.Close() - for _, hooksFilePath := range hooksFiles { + for _, hooksFilePath := range hooksFilesSnapshot() { // set up file watcher log.Printf("setting up file watcher for %s\n", hooksFilePath) @@ -285,6 +321,7 @@ func main() { fmt.Fprint(w, "OK") }) + registerAdminRoutes(r) r.HandleFunc(hooksURL, hookHandler) // Create common HTTP server settings @@ -295,6 +332,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,6 +350,9 @@ 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)) } @@ -317,9 +360,10 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { 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"] @@ -473,6 +517,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 +571,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 +588,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 +617,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 +643,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 +696,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 +758,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) @@ -675,28 +795,21 @@ func reloadHooks(hooksFilePath string) { if err != nil { log.Printf("couldn't load hooks from file! %+v\n", err) } else { - seenHooksIds := make(map[string]bool) + loadedHooksMu.Lock() + defer loadedHooksMu.Unlock() + + candidate := cloneLoadedHooksMapLocked() + candidate[hooksFilePath] = cloneHooks(hooksInFile) + if err := validateUniqueHookIDs(candidate); err != nil { + log.Printf("error: %s", err) + log.Println("reverting hooks back to the previous configuration") + return + } 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) + for _, currentHook := range hooksInFile { + log.Printf("\tloaded: %s\n", currentHook.ID) } loadedHooksFromFiles[hooksFilePath] = hooksInFile @@ -704,14 +817,16 @@ func reloadHooks(hooksFilePath string) { } func reloadAllHooks() { - for _, hooksFilePath := range hooksFiles { + for _, hooksFilePath := range hooksFilesSnapshot() { reloadHooks(hooksFilePath) } } func removeHooks(hooksFilePath string) { - for _, hook := range loadedHooksFromFiles[hooksFilePath] { - log.Printf("\tremoving: %s\n", hook.ID) + loadedHooksMu.Lock() + + for _, currentHook := range loadedHooksFromFiles[hooksFilePath] { + log.Printf("\tremoving: %s\n", currentHook.ID) } newHooksFiles := hooksFiles[:0] @@ -729,7 +844,14 @@ func removeHooks(hooksFilePath string) { log.Printf("removed %d hook(s) that were loaded from file %s\n", removedHooksCount, hooksFilePath) - if !*verbose && !*noPanic && lenLoadedHooks() == 0 { + remainingHooksCount := 0 + for _, hooksInFile := range loadedHooksFromFiles { + remainingHooksCount += len(hooksInFile) + } + + loadedHooksMu.Unlock() + + if !*verbose && !*noPanic && remainingHooksCount == 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") } diff --git a/webhook.yaml.example b/webhook.yaml.example new file mode 100644 index 00000000..960dac83 --- /dev/null +++ b/webhook.yaml.example @@ -0,0 +1,179 @@ +# Webhook 配置文件 +# 用法: webhook -c /etc/webhook/webhook.yaml +# +# 支持所有命令行参数。 +# 列表类参数(hooks、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 +# ============================================================ + +# 当 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 时不要 panic 退出(需配合非 verbose 模式) +# 默认 false +nopanic: true + +# 监视 hook 文件变化,自动热重载 +# 默认 false +hotreload: true + +# 将 hook 文件作为 Go template 解析 +# 默认 false +# template: false + +# multipart 表单解析时的最大内存(字节),超出部分写入磁盘临时文件 +# 默认 1048576 (1MB) +# max-multipart-mem: 1048576 + +# ============================================================ +# 日志设置 +# ============================================================ + +# 启用详细日志输出 +# 默认 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