├── .dockerignore ├── .github ├── dependabot.yml ├── dockerhub.png ├── release.png └── workflows │ ├── build.yml │ ├── codeql.yml │ └── scan.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── README-zhCN.md ├── README.md ├── SECURITY.md ├── docker ├── goreleaser │ ├── Dockerfile │ └── Dockerfile.extend └── manual │ ├── Dockerfile │ └── Dockerfile.alpine ├── docs ├── en-US │ ├── Hook-Definition.md │ ├── Hook-Examples.md │ ├── Hook-Rules.md │ ├── Referencing-Request-Values.md │ ├── Templates.md │ └── Webhook-Parameters.md ├── logo │ ├── logo-1024x1024.jpg │ └── logo-600x600.jpg └── zh-CN │ ├── CLI-ENV.md │ ├── Hook-Definition.md │ ├── Hook-Examples.md │ ├── Hook-Rules.md │ ├── Request-Values.md │ └── Templates.md ├── example ├── configs │ ├── hooks.json │ ├── hooks.json.tmpl │ ├── hooks.yaml │ └── hooks.yaml.tmpl ├── lark │ ├── docker-compose.yml │ ├── hook-lark.yaml │ └── send-lark-message.sh └── muti-webhook │ ├── docker-compose.yml │ ├── hooks │ ├── hook-1.yaml │ └── hook-2.yaml │ └── trigger.sh ├── go.mod ├── go.sum ├── internal ├── flags │ ├── cli.go │ ├── define.go │ ├── envs.go │ └── flags.go ├── fn │ ├── env.go │ ├── env_test.go │ ├── i18n.go │ ├── i18n_test.go │ ├── io.go │ ├── io_test.go │ └── server.go ├── hook │ ├── hook.go │ ├── hook_new_test.go │ ├── hook_test.go │ ├── request.go │ ├── request_test.go │ └── testdata │ │ └── unrecognized.yaml ├── i18n │ ├── i18n.go │ ├── i18n_test.go │ └── id.go ├── link │ ├── link.go │ └── link_test.go ├── middleware │ ├── dumper.go │ ├── logger.go │ └── request_id.go ├── monitor │ ├── monitor.go │ └── watcher.go ├── pidfile │ ├── README.md │ ├── mkdirall.go │ ├── mkdirall_windows.go │ ├── pidfile.go │ ├── pidfile_darwin.go │ ├── pidfile_test.go │ ├── pidfile_unix.go │ └── pidfile_windows.go ├── platform │ ├── droppriv_nope.go │ ├── droppriv_unix.go │ ├── signals.go │ └── signals_windows.go ├── rules │ ├── parse.go │ ├── rules.go │ └── rules_test.go ├── server │ ├── server.go │ ├── web.go │ └── webhook_test.go └── version │ └── version.go ├── locales ├── en-US.toml └── zh-CN.toml ├── test ├── hookecho.go ├── hooks.json.tmpl └── hooks.yaml.tmpl ├── webhook.go └── webhook_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | Dockerfile.* 3 | coverage.out -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/dockerhub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soulteary/webhook/ba7c4651b1a83c72ba7ac3abc28d34efd8a6d028/.github/dockerhub.png -------------------------------------------------------------------------------- /.github/release.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soulteary/webhook/ba7c4651b1a83c72ba7ac3abc28d34efd8a6d028/.github/release.png -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - "main" 8 | tags: 9 | - "*.*.*" 10 | 11 | env: 12 | GO_VERSION: "1.23.0" 13 | GO111MODULE: on 14 | DOCKER_CLI_EXPERIMENTAL: "enabled" 15 | 16 | permissions: 17 | contents: write 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | 28 | - name: Set up Go 29 | uses: actions/setup-go@v5 30 | with: 31 | go-version: ${{ env.GO_VERSION }} 32 | 33 | - name: Set up QEMU 34 | uses: docker/setup-qemu-action@v1 35 | 36 | - name: Cache Go modules 37 | uses: actions/cache@v4 38 | with: 39 | path: ~/go/pkg/mod 40 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 41 | restore-keys: | 42 | ${{ runner.os }}-go- 43 | 44 | - name: Tests 45 | run: | 46 | go mod tidy 47 | go test -v ./... 48 | 49 | - name: Login to Docker Hub 50 | if: github.event_name != 'pull_request' 51 | uses: docker/login-action@v2 52 | with: 53 | username: ${{ secrets.DOCKERHUB_USERNAME }} 54 | password: ${{ secrets.DOCKERHUB_TOKEN }} 55 | 56 | - name: Run GoReleaser 57 | uses: goreleaser/goreleaser-action@v3 58 | if: success() && startsWith(github.ref, 'refs/tags/') 59 | with: 60 | version: latest 61 | args: release --clean 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: ["main"] 7 | paths-ignore: 8 | - "**/*.md" 9 | - "**/*.txt" 10 | - "example" 11 | - "docker" 12 | - "docs" 13 | - "testdata" 14 | pull_request: 15 | branches: ["main"] 16 | paths-ignore: 17 | - "**/*.md" 18 | - "**/*.txt" 19 | - "example" 20 | - "docker" 21 | - "docs" 22 | - "testdata" 23 | schedule: 24 | - cron: "0 0 * * *" 25 | 26 | env: 27 | GO_VERSION: "1.23.0" 28 | 29 | permissions: 30 | actions: read 31 | contents: read 32 | security-events: write 33 | 34 | jobs: 35 | analyze: 36 | name: Analyze 37 | runs-on: ubuntu-latest 38 | 39 | strategy: 40 | fail-fast: false 41 | matrix: 42 | language: ["go"] 43 | 44 | steps: 45 | - name: Checkout repository 46 | uses: actions/checkout@v4 47 | 48 | - name: Set up Go 49 | uses: actions/setup-go@v5 50 | with: 51 | go-version: ${{ env.GO_VERSION }} 52 | 53 | - name: Initialize CodeQL 54 | uses: github/codeql-action/init@v3 55 | with: 56 | languages: ${{ matrix.language }} 57 | config: | 58 | disable-default-queries: true 59 | queries: 60 | - uses: security-extended 61 | query-filters: 62 | - exclude: 63 | id: 64 | - go/clear-text-logging 65 | 66 | - name: Autobuild 67 | uses: github/codeql-action/autobuild@v3 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v3 71 | with: 72 | category: "/language:${{matrix.language}}" 73 | -------------------------------------------------------------------------------- /.github/workflows/scan.yml: -------------------------------------------------------------------------------- 1 | name: "Security Scan" 2 | 3 | # Run workflow each time code is pushed to your repository and on a schedule. 4 | # The scheduled workflow runs every at 00:00 on Sunday UTC time. 5 | on: 6 | workflow_dispatch: 7 | push: 8 | branches: 9 | - main 10 | paths: 11 | - "internal/**" 12 | - "*.go" 13 | pull_request: 14 | branches: 15 | - main 16 | schedule: 17 | - cron: "0 0 * * 0" 18 | 19 | jobs: 20 | scan: 21 | permissions: write-all 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Check out code into the Go module directory 25 | uses: actions/checkout@v4 26 | 27 | - name: Security Scan 28 | uses: securego/gosec@master 29 | with: 30 | args: "-no-fail -fmt sarif -out results.sarif -exclude=G204 ./..." 31 | 32 | - name: Upload SARIF file 33 | uses: github/codeql-action/upload-sarif@v3 34 | with: 35 | sarif_file: results.sarif 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .cover 3 | .DS_Store 4 | coverage 5 | webhook 6 | /test/hookecho 7 | build 8 | coverage.out 9 | coverage.html 10 | config.json 11 | redeploy.sh -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: webhook 2 | 3 | builds: 4 | - <<: &build_defaults 5 | env: 6 | - CGO_ENABLED=0 7 | ldflags: 8 | - -w -s -X "github.com/soulteary/webhook/internal/version.Version={{ .Tag }}" 9 | id: macos 10 | goos: [darwin] 11 | goarch: [amd64, arm64] 12 | 13 | - <<: *build_defaults 14 | id: linux 15 | goos: [linux] 16 | goarch: ["386", arm, amd64, arm64] 17 | goarm: 18 | - "7" 19 | - "6" 20 | 21 | dockers: 22 | - image_templates: 23 | - "soulteary/webhook:linux-amd64-{{ .Tag }}" 24 | - "soulteary/webhook:linux-amd64" 25 | dockerfile: docker/goreleaser/Dockerfile 26 | use: buildx 27 | goarch: amd64 28 | build_flag_templates: 29 | - "--pull" 30 | - "--platform=linux/amd64" 31 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 32 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 33 | - "--label=org.opencontainers.image.url=https://github.com/soulteary/webhook" 34 | - "--label=org.opencontainers.image.source=https://github.com/soulteary/webhook" 35 | - "--label=org.opencontainers.image.version={{ .Version }}" 36 | - "--label=org.opencontainers.image.created={{ .Date }}" 37 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 38 | - "--label=org.opencontainers.image.licenses=MIT" 39 | 40 | - image_templates: 41 | - "soulteary/webhook:linux-arm64-{{ .Tag }}" 42 | - "soulteary/webhook:linux-arm64" 43 | dockerfile: docker/goreleaser/Dockerfile 44 | use: buildx 45 | goos: linux 46 | goarch: arm64 47 | goarm: "" 48 | build_flag_templates: 49 | - "--pull" 50 | - "--platform=linux/arm64" 51 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 52 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 53 | - "--label=org.opencontainers.image.url=https://github.com/soulteary/webhook" 54 | - "--label=org.opencontainers.image.source=https://github.com/soulteary/webhook" 55 | - "--label=org.opencontainers.image.version={{ .Version }}" 56 | - "--label=org.opencontainers.image.created={{ .Date }}" 57 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 58 | - "--label=org.opencontainers.image.licenses=MIT" 59 | 60 | - image_templates: 61 | - "soulteary/webhook:linux-armv7-{{ .Tag }}" 62 | - "soulteary/webhook:linux-armv7" 63 | dockerfile: docker/goreleaser/Dockerfile 64 | use: buildx 65 | goos: linux 66 | goarch: arm 67 | goarm: "7" 68 | build_flag_templates: 69 | - "--pull" 70 | - "--platform=linux/arm/v7" 71 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 72 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 73 | - "--label=org.opencontainers.image.url=https://github.com/soulteary/webhook" 74 | - "--label=org.opencontainers.image.source=https://github.com/soulteary/webhook" 75 | - "--label=org.opencontainers.image.version={{ .Version }}" 76 | - "--label=org.opencontainers.image.created={{ .Date }}" 77 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 78 | - "--label=org.opencontainers.image.licenses=MIT" 79 | 80 | - image_templates: 81 | - "soulteary/webhook:linux-armv6-{{ .Tag }}" 82 | - "soulteary/webhook:linux-armv6" 83 | dockerfile: docker/goreleaser/Dockerfile 84 | use: buildx 85 | goos: linux 86 | goarch: arm 87 | goarm: "6" 88 | build_flag_templates: 89 | - "--pull" 90 | - "--platform=linux/arm/v6" 91 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 92 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 93 | - "--label=org.opencontainers.image.url=https://github.com/soulteary/webhook" 94 | - "--label=org.opencontainers.image.source=https://github.com/soulteary/webhook" 95 | - "--label=org.opencontainers.image.version={{ .Version }}" 96 | - "--label=org.opencontainers.image.created={{ .Date }}" 97 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 98 | - "--label=org.opencontainers.image.licenses=MIT" 99 | 100 | - image_templates: 101 | - "soulteary/webhook:linux-amd64-extend-{{ .Tag }}" 102 | - "soulteary/webhook:linux-amd64-extend" 103 | dockerfile: docker/goreleaser/Dockerfile.extend 104 | use: buildx 105 | goarch: amd64 106 | build_flag_templates: 107 | - "--pull" 108 | - "--platform=linux/amd64" 109 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 110 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 111 | - "--label=org.opencontainers.image.url=https://github.com/soulteary/webhook" 112 | - "--label=org.opencontainers.image.source=https://github.com/soulteary/webhook" 113 | - "--label=org.opencontainers.image.version={{ .Version }}" 114 | - "--label=org.opencontainers.image.created={{ .Date }}" 115 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 116 | - "--label=org.opencontainers.image.licenses=MIT" 117 | 118 | - image_templates: 119 | - "soulteary/webhook:linux-arm64-extend-{{ .Tag }}" 120 | - "soulteary/webhook:linux-arm64-extend" 121 | dockerfile: docker/goreleaser/Dockerfile.extend 122 | use: buildx 123 | goos: linux 124 | goarch: arm64 125 | goarm: "" 126 | build_flag_templates: 127 | - "--pull" 128 | - "--platform=linux/arm64" 129 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 130 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 131 | - "--label=org.opencontainers.image.url=https://github.com/soulteary/webhook" 132 | - "--label=org.opencontainers.image.source=https://github.com/soulteary/webhook" 133 | - "--label=org.opencontainers.image.version={{ .Version }}" 134 | - "--label=org.opencontainers.image.created={{ .Date }}" 135 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 136 | - "--label=org.opencontainers.image.licenses=MIT" 137 | 138 | - image_templates: 139 | - "soulteary/webhook:linux-armv7-extend-{{ .Tag }}" 140 | - "soulteary/webhook:linux-armv7-extend" 141 | dockerfile: docker/goreleaser/Dockerfile.extend 142 | use: buildx 143 | goos: linux 144 | goarch: arm 145 | goarm: "7" 146 | build_flag_templates: 147 | - "--pull" 148 | - "--platform=linux/arm/v7" 149 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 150 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 151 | - "--label=org.opencontainers.image.url=https://github.com/soulteary/webhook" 152 | - "--label=org.opencontainers.image.source=https://github.com/soulteary/webhook" 153 | - "--label=org.opencontainers.image.version={{ .Version }}" 154 | - "--label=org.opencontainers.image.created={{ .Date }}" 155 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 156 | - "--label=org.opencontainers.image.licenses=MIT" 157 | 158 | - image_templates: 159 | - "soulteary/webhook:linux-armv6-extend-{{ .Tag }}" 160 | - "soulteary/webhook:linux-armv6-extend" 161 | dockerfile: docker/goreleaser/Dockerfile.extend 162 | use: buildx 163 | goos: linux 164 | goarch: arm 165 | goarm: "6" 166 | build_flag_templates: 167 | - "--pull" 168 | - "--platform=linux/arm/v6" 169 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 170 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 171 | - "--label=org.opencontainers.image.url=https://github.com/soulteary/webhook" 172 | - "--label=org.opencontainers.image.source=https://github.com/soulteary/webhook" 173 | - "--label=org.opencontainers.image.version={{ .Version }}" 174 | - "--label=org.opencontainers.image.created={{ .Date }}" 175 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 176 | - "--label=org.opencontainers.image.licenses=MIT" 177 | 178 | docker_manifests: 179 | - name_template: "soulteary/webhook:{{ .Tag }}" 180 | image_templates: 181 | - "soulteary/webhook:linux-amd64-{{ .Tag }}" 182 | - "soulteary/webhook:linux-arm64-{{ .Tag }}" 183 | - "soulteary/webhook:linux-armv7-{{ .Tag }}" 184 | - "soulteary/webhook:linux-armv6-{{ .Tag }}" 185 | skip_push: "false" 186 | 187 | - name_template: "soulteary/webhook:extend-{{ .Tag }}" 188 | image_templates: 189 | - "soulteary/webhook:linux-amd64-extend-{{ .Tag }}" 190 | - "soulteary/webhook:linux-arm64-extend-{{ .Tag }}" 191 | - "soulteary/webhook:linux-armv7-extend-{{ .Tag }}" 192 | - "soulteary/webhook:linux-armv6-extend-{{ .Tag }}" 193 | skip_push: "false" 194 | 195 | - name_template: "soulteary/webhook:latest" 196 | image_templates: 197 | - "soulteary/webhook:linux-amd64-{{ .Tag }}" 198 | - "soulteary/webhook:linux-arm64-{{ .Tag }}" 199 | - "soulteary/webhook:linux-armv7-{{ .Tag }}" 200 | - "soulteary/webhook:linux-armv6-{{ .Tag }}" 201 | skip_push: "false" 202 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 soulteary 4 | Copyright (c) 2015 Adnan Hajdarevic 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README-zhCN.md: -------------------------------------------------------------------------------- 1 | # 什么是 WebHook (歪脖虎克)? 2 | 3 | [![Release](https://github.com/soulteary/webhook/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/soulteary/webhook/actions/workflows/build.yml) [![CodeQL](https://github.com/soulteary/webhook/actions/workflows/codeql.yml/badge.svg)](https://github.com/soulteary/webhook/actions/workflows/codeql.yml) [![Security Scan](https://github.com/soulteary/webhook/actions/workflows/scan.yml/badge.svg)](https://github.com/soulteary/webhook/actions/workflows/scan.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/soulteary/webhook)](https://goreportcard.com/report/github.com/soulteary/webhook) 4 | 5 | Webhook 6 | 7 | 歪脖虎克([WebHook][w])是一个用 Go 语言编写的轻量可配置的实用工具,它允许你轻松、快速的创建 HTTP 服务(钩子)。你可以使用它来执行配置好的命令。并且还能够将 HTTP 请求中的数据(如请求头内容、请求体以及请求参数)灵活的传递给你配置好的命令、程序。当然,它也允许根据具体的条件规则来便触发钩子。 8 | 9 | 举个例子,如果你使用的是 GitHub 或 Gitea,可以使用歪脖虎克设置一个钩子,在每次你推送更改到项目的某个分支时,这个钩子会在你运行服务的设备上运行一个“更新程序部署内容”的脚本。 10 | 11 | 如果你使用飞书、钉钉、企业微信或者 Slack,你也可以设置一个“传出 Webhook 集成”或“斜杠命令”,来在你的服务器上运行各种命令。我们可以通过聊天工具的“传入 Webhook 集成”功能处理接口的响应内容,直接向你或你的 IM 会话或频道报告执行结果。 12 | 13 | 歪脖虎克([WebHook][w])的项目目标非常简单,**只做它应该做的事情**: 14 | 15 | - 接收请求, 16 | - 解析请求头、请求体和请求参数, 17 | - 检查钩子指定的运行规则是否得到满足, 18 | - 最后,通过命令行参数或环境变量将指定的参数传递给指定的命令。 19 | 20 | 至于具体的命令,从处理数据、存储数据到用远程命令打开空调、关闭电脑,一些都由你做主,你可以实现任何你想要的事情,它只负责在合适的时间点,接受执行指令。 21 | 22 | # 快速上手 23 | 24 | 如何下载和获得执行程序、如何快速启动程序,开始连接各种应用。 25 | 26 | ## 软件安装:下载预构建程序 27 | 28 | [![](.github/release.png)](https://github.com/soulteary/webhook/releases) 29 | 30 | 不同架构的预编译二进制文件可在 [GitHub 发布](https://github.com/soulteary/webhook/releases) 页面获取。 31 | 32 | ## 软件安装:Docker 33 | 34 | ![](.github/dockerhub.png) 35 | 36 | 你可以使用下面的任一命令来下载本仓库自动构建的可执行程序镜像: 37 | 38 | ```bash 39 | docker pull soulteary/webhook:latest 40 | docker pull soulteary/webhook:3.6.3 41 | ``` 42 | 43 | 如果你希望镜像中有一些方便调试的工具,可以使用下面的命令,获取扩展版的镜像: 44 | 45 | ```bash 46 | docker pull soulteary/webhook:extend-3.6.3 47 | ``` 48 | 49 | 然后我们可以基于这个镜像来构建和完善我们命令所需要的运行环境。 50 | 51 | # 程序配置 52 | 53 | **建议阅读完整文档,来了解程序的具体能力,[中文文档](./docs/zh-CN/),[英文文档](./docs/en-US/)。** 54 | 55 | --- 56 | 57 | 我们可以来定义一些你希望 [webhook][w] 提供 HTTP 服务使用的钩子。 58 | 59 | [webhook][w] 支持 JSON 或 YAML 配置文件,我们先来看看如何实现 JSON 配置。 60 | 61 | 首先,创建一个名为 hooks.json 的空文件。这个文件将包含 [webhook][w] 将要启动为 HTTP 服务的钩子的数组。查看 [钩子定义](docs/zh-CN/Hook-Definition.md)文档,可以了解钩子可以包含哪些属性,以及如何使用它们的详细描述。 62 | 63 | 让我们定义一个简单的名为 redeploy-webhook 的钩子,它将运行位于 `/var/scripts/redeploy.sh` 的重新部署脚本。确保你的 bash 脚本在顶部有 `#!/bin/sh`。 64 | 65 | 我们的 hooks.json 文件将如下所示: 66 | 67 | ```json 68 | [ 69 | { 70 | "id": "redeploy-webhook", 71 | "execute-command": "/var/scripts/redeploy.sh", 72 | "command-working-directory": "/var/webhook" 73 | } 74 | ] 75 | ``` 76 | 77 | 如果你更喜欢使用 YAML,相应的 hooks.yaml 文件内容为: 78 | 79 | ```yaml 80 | - id: redeploy-webhook 81 | execute-command: "/var/scripts/redeploy.sh" 82 | command-working-directory: "/var/webhook" 83 | ``` 84 | 85 | 接下来,你可以通过下面的命令来执行 [webhook][w]: 86 | 87 | ```bash 88 | $ /path/to/webhook -hooks hooks.json -verbose 89 | ``` 90 | 91 | 程序将在默认的 9000 端口启动,并提供一个公开可访问的 HTTP 服务地址: 92 | 93 | ```bash 94 | http://yourserver:9000/hooks/redeploy-webhook 95 | ``` 96 | 97 | 查看 [配置参数](docs/zh-CN/CLI-ENV.md) 文档,可以了解如何在启动 [webhook][w] 时设置 IP、端口以及其它设置,例如钩子的热重载,详细输出等。 98 | 99 | 当有任何 HTTP GET 或 POST 请求访问到服务地址后,你设置的重新部署脚本将被执行。 100 | 101 | 不过,像这样定义的钩子可能会对你的系统构成安全威胁,因为任何知道你端点的人都可以发送请求并执行命令。为了防止这种情况,你可以使用钩子的 "trigger-rule" 属性来指定触发钩子的确切条件。例如,你可以使用它们添加一个秘密参数,必须提供这个参数才能成功触发钩子。请查看 [钩子匹配规则](docs/zh-CN/Hook-Rules.md)文档,来获取可用规则及其使用方法的详细列表。 102 | 103 | ## 表单数据 104 | 105 | [webhook][w] 提供了对表单数据的有限解析支持。 106 | 107 | 表单数据通常可以包含两种类型的部分:值和文件。 108 | 所有表单 _值_ 会自动添加到 `payload` 范围内。 109 | 使用 `parse-parameters-as-json` 设置将给定值解析为 JSON。 110 | 除非符合以下标准之一,否则所有文件都会被忽略: 111 | 112 | 1. `Content-Type` 标头是 `application/json`。 113 | 2. 部分在 `parse-parameters-as-json` 设置中被命名。 114 | 115 | 在任一情况下,给定的文件部分将被解析为 JSON 并添加到 payload 映射中。 116 | 117 | ## 模版 118 | 119 | 当使用 `-template` [命令行参数](docs/zh-CN/CLI-ENV.md)时,[webhook][w] 可以将钩子配置文件解析为 Go 模板。有关模板使用的更多详情,请查看[配置模版](docs/zh-CN/Templates.md)。 120 | 121 | ## 使用 HTTPS 122 | 123 | [webhook][w] 默认使用 http 提供服务。如果你希望 [webhook][w] 使用 https 提供 HTTPS 服务,更简单的方案是使用反向代理或者使用 traefik 等服务来提供 HTTPS 服务。 124 | 125 | ## 跨域 CORS 请求头 126 | 127 | 如果你想设置 CORS 头,可以在启动 [webhook][w] 时使用 `-header name=value` 标志来设置将随每个响应返回的适当 CORS 头。 128 | 129 | ## 使用示例 130 | 131 | 查看 [钩子示例](docs/zh-CN/Hook-Examples.md) 来了解各种使用方法。 132 | 133 | # 为什么要作一个开源软件的分叉 134 | 135 | 主要有两个原因: 136 | 137 | 1. 原作者维护的 `webhook` 程序版本,是从比较陈旧的 Go 程序版本慢慢升级上来的。 138 | - 其中,包含了许多不再被需要的内容,以及非常多的安全问题亟待修正。 139 | 140 | 2. 我在几年前曾经提交过一个[改进版本的 PR](https://github.com/adnanh/webhook/pull/570),但是因为种种原因被作者忽略,**与其继续使用明知道不可靠的程序,不如将它变的可靠。** 141 | - 这样,除了更容易从社区合并未被原始仓库作者合并的社区功能外,还可以快速对有安全风险的依赖作更新。除此之外,我希望这个程序接下来能够中文更加友好,包括文档。 142 | 143 | [w]: https://github.com/soulteary/webhook 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to WebHook! [中文文档](./README-zhCN.md) 2 | 3 | [![Release](https://github.com/soulteary/webhook/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/soulteary/webhook/actions/workflows/build.yml) [![CodeQL](https://github.com/soulteary/webhook/actions/workflows/codeql.yml/badge.svg)](https://github.com/soulteary/webhook/actions/workflows/codeql.yml) [![Security Scan](https://github.com/soulteary/webhook/actions/workflows/scan.yml/badge.svg)](https://github.com/soulteary/webhook/actions/workflows/scan.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/soulteary/webhook)](https://goreportcard.com/report/github.com/soulteary/webhook) 4 | 5 | Webhook 6 | 7 | [WebHook][w] is a lightweight and customizable tool written in Go that enables you to effortlessly create HTTP WebHook services. With WebHook, you can execute predefined commands and flexibly pass data from HTTP requests (including headers, body, and parameters) to your configured commands or programs. It also supports triggering hooks based on specific conditions. 8 | 9 | For example, if you're using GitHub or Gitea, you can set up a hook with WebHook to automatically update your deployed program whenever you push changes to a specific branch of your project. 10 | 11 | If you use Discord, Slack, or other messaging platforms, you can create an "Outgoing Webhook Integration" or "Slash Command" to run various commands on your server. You can then use the "Incoming Webhook Integration" feature of your messaging tool to report the execution results directly to you or your conversation channel. 12 | 13 | The [WebHook][w] project has a straightforward goal: **to do exactly what it's designed for.** 14 | 15 | - Receive requests 16 | - Parse request headers, body, and parameters 17 | - Verify if the hook's execution rules are met 18 | - Pass specified parameters to the designated command via command-line arguments or environment variables 19 | 20 | The specific commands - whether processing data, storing information, or controlling devices - are entirely up to you. WebHook's role is to accept and execute instructions at the appropriate time. 21 | 22 | # Getting Started 23 | 24 | Let's explore how to download the executable program and quickly set it up to connect various applications. 25 | 26 | ## Software Installation: Downloading Pre-built Programs 27 | 28 | [![](.github/release.png)](https://github.com/soulteary/webhook/releases) 29 | 30 | WebHook offers pre-built executable programs for various operating systems and architectures. You can download the version suitable for your platform from the [Releases page on GitHub](https://github.com/soulteary/webhook/releases). 31 | 32 | ## Software Installation: Docker 33 | 34 | ![](.github/dockerhub.png) 35 | 36 | You can use any of the following commands to download the automatically built executable program image: 37 | 38 | ```bash 39 | docker pull soulteary/webhook:latest 40 | docker pull soulteary/webhook:3.6.3 41 | ``` 42 | 43 | For an extended version of the image that includes debugging tools, use: 44 | 45 | ```bash 46 | docker pull soulteary/webhook:extend-3.6.3 47 | ``` 48 | 49 | You can then build and refine the runtime environment required for your commands based on this image. 50 | 51 | ## Program Configuration 52 | 53 | **We recommend reading the complete documentation to fully understand the program's capabilities. [English Documentation](./docs/en-US/), [Chinese Documentation](./docs/zh-CN/)** 54 | 55 | --- 56 | 57 | Let's define some hooks for [webhook][w] to provide HTTP services. 58 | 59 | [webhook][w] supports both JSON and YAML configuration files. We'll start with JSON configuration. 60 | 61 | Create an empty file named `hooks.json`. This file will contain an array of hooks that [webhook][w] will start as HTTP services. For detailed information on hook properties and usage, please refer to the [Hook Definition page](docs/en-US/Hook-Definition.md). 62 | 63 | Here's a simple hook named `redeploy-webhook` that runs a redeployment script located at `/var/scripts/redeploy.sh`: 64 | 65 | ```json 66 | [ 67 | { 68 | "id": "redeploy-webhook", 69 | "execute-command": "/var/scripts/redeploy.sh", 70 | "command-working-directory": "/var/webhook" 71 | } 72 | ] 73 | ``` 74 | 75 | If you prefer YAML, the equivalent `hooks.yaml` file would look like this: 76 | 77 | ```yaml 78 | - id: redeploy-webhook 79 | execute-command: "/var/scripts/redeploy.sh" 80 | command-working-directory: "/var/webhook" 81 | ``` 82 | 83 | To run [webhook][w], use the following command: 84 | 85 | ```bash 86 | $ /path/to/webhook -hooks hooks.json -verbose 87 | ``` 88 | 89 | The program will start on the default port `9000` and provide a publicly accessible HTTP service address: 90 | 91 | ```bash 92 | http://yourserver:9000/hooks/redeploy-webhook 93 | ``` 94 | 95 | To learn how to customize IP, port, and other settings when starting [webhook][w], check out the [webhook parameters](docs/en-US/Webhook-Parameters.md) documentation. 96 | 97 | Any HTTP `GET` or `POST` request to the service address will trigger the redeploy script. 98 | 99 | To enhance security and prevent unauthorized access, you can use the "trigger-rule" property to specify exact conditions for hook triggering. For a detailed list of available rules and their usage, please refer to [Hook Rules](docs/en-US/Hook-Rules.md). 100 | 101 | ## Form Data 102 | 103 | [webhook][w] offers limited parsing support for form data, including both values and files. For more details on how form data is handled, please refer to the [Form Data](docs/en-US/Form-Data.md) documentation. 104 | 105 | ## Templates 106 | 107 | [webhook][w] supports parsing the hook configuration file as a Go template when using the `-template` [command line argument](docs/en-US/Webhook-Parameters.md). For more information on template usage, see [Templates](docs/en-US/Templates.md). 108 | 109 | ## Using HTTPS 110 | 111 | While [webhook][w] serves using HTTP by default, we recommend using a reverse proxy or a service like Traefik to provide HTTPS service for enhanced security. 112 | 113 | ## Cross-Origin CORS Request Headers 114 | 115 | To set CORS headers, use the `-header name=value` flag when starting [webhook][w]. This will ensure the appropriate CORS headers are returned with each response. 116 | 117 | ## Usage Examples 118 | 119 | Explore various creative uses of WebHook in our [Hook Examples](docs/en-US/Hook-Examples.md) documentation. 120 | 121 | # Our Motivation 122 | 123 | We decided to fork this open-source software for two main reasons: 124 | 125 | 1. To address security issues and outdated dependencies in the original version. 126 | 2. To incorporate community-contributed features and improvements that were not merged into the original repository. 127 | 128 | Our goal is to make WebHook more reliable, secure, and user-friendly, including improved documentation for our Chinese users. 129 | 130 | [w]: https://github.com/soulteary/webhook 131 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Current support status of each version. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 3.4.x | :white_check_mark: | 10 | | < 3.4.1 | :x: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | If you find or encounter security-related issues, you are welcome to raise them in [Issues](https://github.com/soulteary/webhook/issues). 15 | -------------------------------------------------------------------------------- /docker/goreleaser/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.19.0 as builder 2 | RUN apk --update add ca-certificates 3 | 4 | FROM scratch 5 | LABEL maintainer "soulteary " 6 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 7 | COPY webhook /usr/bin/webhook 8 | EXPOSE 9000/tcp 9 | CMD ["/usr/bin/webhook"] 10 | -------------------------------------------------------------------------------- /docker/goreleaser/Dockerfile.extend: -------------------------------------------------------------------------------- 1 | FROM alpine:3.19.0 as builder 2 | RUN apk --update add ca-certificates 3 | 4 | FROM alpine:3.19.0 5 | LABEL maintainer "soulteary " 6 | RUN apk --update add bash curl wget jq yq 7 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 8 | COPY webhook /usr/bin/webhook 9 | EXPOSE 9000/tcp 10 | CMD ["/usr/bin/webhook"] 11 | -------------------------------------------------------------------------------- /docker/manual/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-bullseye AS Builder 2 | ENV GOPROXY=https://goproxy.cn 3 | ENV CGO_ENABLED=0 4 | WORKDIR /app 5 | COPY . . 6 | RUN go mod download -x 7 | RUN go build -ldflags "-w -s" -o webhook . 8 | 9 | FROM debian:stretch 10 | LABEL maintainer "soulteary " 11 | COPY --from=builder /app/webhook /bin/ 12 | EXPOSE 9000/tcp 13 | CMD webhook 14 | -------------------------------------------------------------------------------- /docker/manual/Dockerfile.alpine: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-alpine3.19 as builder 2 | RUN apk --update add ca-certificates upx 3 | ENV GOPROXY=https://goproxy.cn 4 | ENV CGO_ENABLED=0 5 | WORKDIR /app 6 | COPY . . 7 | RUN go mod download -x 8 | RUN go build -ldflags "-w -s" -o webhook . 9 | RUN upx -9 -o webhook.minify webhook && \ 10 | chmod +x webhook.minify 11 | 12 | FROM alpine:3.19 13 | LABEL maintainer "soulteary " 14 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 15 | COPY webhook.minify /usr/bin/webhook 16 | EXPOSE 9000/tcp 17 | CMD webhook 18 | -------------------------------------------------------------------------------- /docs/en-US/Hook-Definition.md: -------------------------------------------------------------------------------- 1 | # Hook definition 2 | 3 | Hooks are defined as objects in the JSON or YAML hooks configuration file. Please note that in order to be considered valid, a hook object must contain the `id` and `execute-command` properties. All other properties are considered optional. 4 | 5 | ## Properties (keys) 6 | 7 | * `id` - specifies the ID of your hook. This value is used to create the HTTP endpoint (http://yourserver:port/hooks/your-hook-id) 8 | * `execute-command` - specifies the command that should be executed when the hook is triggered 9 | * `command-working-directory` - specifies the working directory that will be used for the script when it's executed 10 | * `response-message` - specifies the string that will be returned to the hook initiator 11 | * `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 12 | * `success-http-response-code` - specifies the HTTP status code to be returned upon success 13 | * `incoming-payload-content-type` - sets the `Content-Type` of the incoming HTTP request (ie. `application/json`); useful when the request lacks a `Content-Type` or sends an erroneous value 14 | * `http-methods` - a list of allowed HTTP methods, such as `POST` and `GET` 15 | * `include-command-output-in-response` - boolean whether webhook should wait for the command to finish and return the raw output as a response to the hook initiator. If the command fails to execute or encounters any errors while executing the response will result in 500 Internal Server Error HTTP status code, otherwise the 200 OK status code will be returned. 16 | * `include-command-output-in-response-on-error` - boolean whether webhook should include command stdout & stderror as a response in failed executions. It only works if `include-command-output-in-response` is set to `true`. 17 | * `parse-parameters-as-json` - specifies the list of arguments that contain JSON strings. These parameters will be decoded by webhook and you can access them like regular objects in rules and `pass-arguments-to-command`. 18 | * `pass-arguments-to-command` - specifies the list of arguments that will be passed to the command. Check [Referencing request values page](Referencing-Request-Values.md) to see how to reference the values from the request. If you want to pass a static string value to your command you can specify it as 19 | `{ "source": "string", "name": "argumentvalue" }` 20 | * `pass-environment-to-command` - specifies the list of arguments that will be passed to the command as environment variables. If you do not specify the `"envname"` field in the referenced value, the hook will be in format "HOOK_argumentname", otherwise "envname" field will be used as it's name. Check [Referencing request values page](Referencing-Request-Values.md) to see how to reference the values from the request. If you want to pass a static string value to your command you can specify it as 21 | `{ "source": "string", "envname": "SOMETHING", "name": "argumentvalue" }` 22 | * `pass-file-to-command` - specifies a list of entries that will be serialized as a file. Incoming [data](Referencing-Request-Values.md) will be serialized in a request-temporary-file (otherwise parallel calls of the hook would lead to concurrent overwritings of the file). The filename to be addressed within the subsequent script is provided via an environment variable. Use `envname` to specify the name of the environment variable. If `envname` is not provided `HOOK_` and the name used to reference the request value are used. Defining `command-working-directory` will store the file relative to this location, if not provided, the systems temporary file directory will be used. If `base64decode` is true, the incoming binary data will be base 64 decoded prior to storing it into the file. By default the corresponding file will be removed after the webhook exited. 23 | * `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 24 | * `trigger-rule-mismatch-http-response-code` - specifies the HTTP status code to be returned when the trigger rule is not satisfied 25 | * `trigger-signature-soft-failures` - allow signature validation failures within Or rules; by default, signature failures are treated as errors. 26 | 27 | ## Examples 28 | Check out [Hook examples page](Hook-Examples.md) for more complex examples of hooks. 29 | -------------------------------------------------------------------------------- /docs/en-US/Hook-Rules.md: -------------------------------------------------------------------------------- 1 | # Hook rules 2 | 3 | ### Table of Contents 4 | 5 | * [And](#and) 6 | * [Or](#or) 7 | * [Not](#not) 8 | * [Multi-level](#multi-level) 9 | * [Match](#match) 10 | * [Match value](#match-value) 11 | * [Match regex](#match-regex) 12 | * [Match payload-hmac-sha1](#match-payload-hmac-sha1) 13 | * [Match payload-hmac-sha256](#match-payload-hmac-sha256) 14 | * [Match payload-hmac-sha512](#match-payload-hmac-sha512) 15 | * [Match Whitelisted IP range](#match-whitelisted-ip-range) 16 | * [Match scalr-signature](#match-scalr-signature) 17 | 18 | ## And 19 | *And rule* will evaluate to _true_, if and only if all of the sub rules evaluate to _true_. 20 | ```json 21 | { 22 | "and": 23 | [ 24 | { 25 | "match": 26 | { 27 | "type": "value", 28 | "value": "refs/heads/master", 29 | "parameter": 30 | { 31 | "source": "payload", 32 | "name": "ref" 33 | } 34 | } 35 | }, 36 | { 37 | "match": 38 | { 39 | "type": "regex", 40 | "regex": ".*", 41 | "parameter": 42 | { 43 | "source": "payload", 44 | "name": "repository.owner.name" 45 | } 46 | } 47 | } 48 | ] 49 | } 50 | ``` 51 | ## Or 52 | *Or rule* will evaluate to _true_, if any of the sub rules evaluate to _true_. 53 | ```json 54 | { 55 | "or": 56 | [ 57 | { 58 | "match": 59 | { 60 | "type": "value", 61 | "value": "refs/heads/master", 62 | "parameter": 63 | { 64 | "source": "payload", 65 | "name": "ref" 66 | } 67 | } 68 | }, 69 | { 70 | "match": 71 | { 72 | "type": "value", 73 | "value": "refs/heads/development", 74 | "parameter": 75 | { 76 | "source": "payload", 77 | "name": "ref" 78 | } 79 | } 80 | } 81 | ] 82 | } 83 | ``` 84 | ## Not 85 | *Not rule* will evaluate to _true_, if and only if the sub rule evaluates to _false_. 86 | ```json 87 | { 88 | "not": 89 | { 90 | "match": 91 | { 92 | "type": "value", 93 | "value": "refs/heads/development", 94 | "parameter": 95 | { 96 | "source": "payload", 97 | "name": "ref" 98 | } 99 | } 100 | } 101 | } 102 | ``` 103 | ## Multi-level 104 | ```json 105 | { 106 | "and": [ 107 | { 108 | "match": { 109 | "parameter": { 110 | "source": "header", 111 | "name": "X-Hub-Signature" 112 | }, 113 | "type": "payload-hmac-sha1", 114 | "secret": "mysecret" 115 | } 116 | }, 117 | { 118 | "or": [ 119 | { 120 | "match": 121 | { 122 | "parameter": 123 | { 124 | "source": "payload", 125 | "name": "ref" 126 | }, 127 | "type": "value", 128 | "value": "refs/heads/master" 129 | } 130 | }, 131 | { 132 | "match": 133 | { 134 | "parameter": 135 | { 136 | "source": "header", 137 | "name": "X-GitHub-Event" 138 | }, 139 | "type": "value", 140 | "value": "ping" 141 | } 142 | } 143 | ] 144 | } 145 | ] 146 | } 147 | ``` 148 | ## Match 149 | *Match rule* will evaluate to _true_, if and only if the referenced value in the `parameter` field satisfies the `type`-specific rule. 150 | 151 | *Please note:* Due to technical reasons, _number_ and _boolean_ values in the _match rule_ must be wrapped around with a pair of quotes. 152 | 153 | ### Match value 154 | ```json 155 | { 156 | "match": 157 | { 158 | "type": "value", 159 | "value": "refs/heads/development", 160 | "parameter": 161 | { 162 | "source": "payload", 163 | "name": "ref" 164 | } 165 | } 166 | } 167 | ``` 168 | 169 | ### Match regex 170 | For the regex syntax, check out 171 | ```json 172 | { 173 | "match": 174 | { 175 | "type": "regex", 176 | "regex": ".*", 177 | "parameter": 178 | { 179 | "source": "payload", 180 | "name": "ref" 181 | } 182 | } 183 | } 184 | ``` 185 | 186 | ### Match payload-hmac-sha1 187 | Validate the HMAC of the payload using the SHA1 hash and the given *secret*. 188 | ```json 189 | { 190 | "match": 191 | { 192 | "type": "payload-hmac-sha1", 193 | "secret": "yoursecret", 194 | "parameter": 195 | { 196 | "source": "header", 197 | "name": "X-Hub-Signature" 198 | } 199 | } 200 | } 201 | ``` 202 | 203 | Note that if multiple signatures were passed via a comma separated string, each 204 | will be tried unless a match is found. For example: 205 | 206 | ``` 207 | X-Hub-Signature: sha1=the-first-signature,sha1=the-second-signature 208 | ``` 209 | 210 | ### Match payload-hmac-sha256 211 | Validate the HMAC of the payload using the SHA256 hash and the given *secret*. 212 | ```json 213 | { 214 | "match": 215 | { 216 | "type": "payload-hmac-sha256", 217 | "secret": "yoursecret", 218 | "parameter": 219 | { 220 | "source": "header", 221 | "name": "X-Signature" 222 | } 223 | } 224 | } 225 | ``` 226 | 227 | Note that if multiple signatures were passed via a comma separated string, each 228 | will be tried unless a match is found. For example: 229 | 230 | ``` 231 | X-Hub-Signature: sha256=the-first-signature,sha256=the-second-signature 232 | ``` 233 | 234 | ### Match payload-hmac-sha512 235 | Validate the HMAC of the payload using the SHA512 hash and the given *secret*. 236 | ```json 237 | { 238 | "match": 239 | { 240 | "type": "payload-hmac-sha512", 241 | "secret": "yoursecret", 242 | "parameter": 243 | { 244 | "source": "header", 245 | "name": "X-Signature" 246 | } 247 | } 248 | } 249 | ``` 250 | 251 | Note that if multiple signatures were passed via a comma separated string, each 252 | will be tried unless a match is found. For example: 253 | 254 | ``` 255 | X-Hub-Signature: sha512=the-first-signature,sha512=the-second-signature 256 | ``` 257 | 258 | ### Match Whitelisted IP range 259 | 260 | The IP can be IPv4- or IPv6-formatted, using [CIDR notation](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_blocks). To match a single IP address only, use `/32`. 261 | 262 | ```json 263 | { 264 | "match": 265 | { 266 | "type": "ip-whitelist", 267 | "ip-range": "192.168.0.1/24" 268 | } 269 | } 270 | ``` 271 | 272 | ### Match scalr-signature 273 | 274 | The trigger rule checks the scalr signature and also checks that the request was signed less than 5 minutes before it was received. 275 | A unqiue signing key is generated for each webhook endpoint URL you register in Scalr. 276 | Given the time check make sure that NTP is enabled on both your Scalr and webhook server to prevent any issues 277 | 278 | ```json 279 | { 280 | "match": 281 | { 282 | "type": "scalr-signature", 283 | "secret": "Scalr-provided signing key" 284 | } 285 | } 286 | ``` 287 | -------------------------------------------------------------------------------- /docs/en-US/Referencing-Request-Values.md: -------------------------------------------------------------------------------- 1 | # Referencing request values 2 | There are four types of request values: 3 | 4 | 1. HTTP Request Header values 5 | 6 | ```json 7 | { 8 | "source": "header", 9 | "name": "Header-Name" 10 | } 11 | ``` 12 | 13 | 2. HTTP Query parameters 14 | 15 | ```json 16 | { 17 | "source": "url", 18 | "name": "parameter-name" 19 | } 20 | ``` 21 | 22 | 3. HTTP Request parameters 23 | 24 | ```json 25 | { 26 | "source": "request", 27 | "name": "method" 28 | } 29 | ``` 30 | 31 | ```json 32 | { 33 | "source": "request", 34 | "name": "remote-addr" 35 | } 36 | ``` 37 | 38 | 4. Payload (JSON or form-value encoded) 39 | ```json 40 | { 41 | "source": "payload", 42 | "name": "parameter-name" 43 | } 44 | ``` 45 | 46 | *Note:* For JSON encoded payload, you can reference nested values using the dot-notation. 47 | For example, if you have following JSON payload 48 | 49 | ```json 50 | { 51 | "commits": [ 52 | { 53 | "commit": { 54 | "id": 1 55 | } 56 | }, { 57 | "commit": { 58 | "id": 2 59 | } 60 | } 61 | ] 62 | } 63 | ``` 64 | 65 | You can reference the first commit id as 66 | 67 | ```json 68 | { 69 | "source": "payload", 70 | "name": "commits.0.commit.id" 71 | } 72 | ``` 73 | 74 | If the payload contains a key with the specified name "commits.0.commit.id", then the value of that key has priority over the dot-notation referencing. 75 | 76 | 4. XML Payload 77 | 78 | Referencing XML payload parameters is much like the JSON examples above, but XML is more complex. 79 | Element attributes are prefixed by a hyphen (`-`). 80 | Element values are prefixed by a pound (`#`). 81 | 82 | Take the following XML payload: 83 | 84 | ```xml 85 | 86 | 87 | 88 | 89 | 90 | 91 | Hello!! 92 | 93 | 94 | ``` 95 | 96 | To access a given `user` element, you must treat them as an array. 97 | So `app.users.user.0.name` yields `Jeff`. 98 | 99 | Since there's only one `message` tag, it's not treated as an array. 100 | So `app.messages.message.id` yields `1`. 101 | 102 | To access the text within the `message` tag, you would use: `app.messages.message.#text`. 103 | 104 | If you are referencing values for environment, you can use `envname` property to set the name of the environment variable like so 105 | ```json 106 | { 107 | "source": "url", 108 | "name": "q", 109 | "envname": "QUERY" 110 | } 111 | ``` 112 | to get the QUERY environment variable set to the `q` parameter passed in the query string. 113 | 114 | # Special cases 115 | If you want to pass the entire payload as JSON string to your command you can use 116 | ```json 117 | { 118 | "source": "entire-payload" 119 | } 120 | ``` 121 | 122 | for headers you can use 123 | ```json 124 | { 125 | "source": "entire-headers" 126 | } 127 | ``` 128 | 129 | and for query variables you can use 130 | ```json 131 | { 132 | "source": "entire-query" 133 | } 134 | ``` 135 | -------------------------------------------------------------------------------- /docs/en-US/Templates.md: -------------------------------------------------------------------------------- 1 | # Templates in Webhook 2 | 3 | [`webhook`][w] can parse a hooks configuration file as a Go template when given the `-template` [CLI parameter](Webhook-Parameters.md). 4 | 5 | In additional to the [built-in Go template functions and features][tt], `webhook` provides a `getenv` template function for inserting environment variables into a templated configuration file. 6 | 7 | ## Example Usage 8 | 9 | In the example JSON template file below (YAML is also supported), the `payload-hmac-sha1` matching rule looks up the HMAC secret from the environment using the `getenv` template function. 10 | Additionally, the result is piped through the built-in Go template function `js` to ensure that the result is a well-formed Javascript/JSON string. 11 | 12 | ``` 13 | [ 14 | { 15 | "id": "webhook", 16 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 17 | "command-working-directory": "/home/adnan/go", 18 | "response-message": "I got the payload!", 19 | "response-headers": 20 | [ 21 | { 22 | "name": "Access-Control-Allow-Origin", 23 | "value": "*" 24 | } 25 | ], 26 | "pass-arguments-to-command": 27 | [ 28 | { 29 | "source": "payload", 30 | "name": "head_commit.id" 31 | }, 32 | { 33 | "source": "payload", 34 | "name": "pusher.name" 35 | }, 36 | { 37 | "source": "payload", 38 | "name": "pusher.email" 39 | } 40 | ], 41 | "trigger-rule": 42 | { 43 | "and": 44 | [ 45 | { 46 | "match": 47 | { 48 | "type": "payload-hmac-sha1", 49 | "secret": "{{ getenv "XXXTEST_SECRET" | js }}", 50 | "parameter": 51 | { 52 | "source": "header", 53 | "name": "X-Hub-Signature" 54 | } 55 | } 56 | }, 57 | { 58 | "match": 59 | { 60 | "type": "value", 61 | "value": "refs/heads/master", 62 | "parameter": 63 | { 64 | "source": "payload", 65 | "name": "ref" 66 | } 67 | } 68 | } 69 | ] 70 | } 71 | } 72 | ] 73 | 74 | ``` 75 | 76 | [w]: https://github.com/adnanh/webhook 77 | [tt]: https://golang.org/pkg/text/template/ 78 | -------------------------------------------------------------------------------- /docs/en-US/Webhook-Parameters.md: -------------------------------------------------------------------------------- 1 | # Webhook parameters 2 | ``` 3 | Usage of webhook: 4 | -debug 5 | show debug output 6 | -header value 7 | response header to return, specified in format name=value, use multiple times to set multiple headers 8 | -hooks value 9 | path to the json file containing defined hooks the webhook should serve, use multiple times to load from different files 10 | -hotreload 11 | watch hooks file for changes and reload them automatically 12 | -http-methods string 13 | set default allowed HTTP methods (ie. "POST"); separate methods with comma 14 | -ip string 15 | ip the webhook should serve hooks on (default "0.0.0.0") 16 | -logfile string 17 | send log output to a file; implicitly enables verbose logging 18 | -max-multipart-mem int 19 | maximum memory in bytes for parsing multipart form data before disk caching (default 1048576) 20 | -nopanic 21 | do not panic if hooks cannot be loaded when webhook is not running in verbose mode 22 | -pidfile string 23 | create PID file at the given path 24 | -port int 25 | port the webhook should serve hooks on (default 9000) 26 | -setgid int 27 | set group ID after opening listening port; must be used with setuid 28 | -setuid int 29 | set user ID after opening listening port; must be used with setgid 30 | -template 31 | parse hooks file as a Go template 32 | -urlprefix string 33 | url prefix to use for served hooks (protocol://yourserver:port/PREFIX/:hook-id) (default "hooks") 34 | -verbose 35 | show verbose output 36 | -version 37 | display webhook version and quit 38 | -x-request-id 39 | use X-Request-Id header, if present, as request ID 40 | -x-request-id-limit int 41 | truncate X-Request-Id header to limit; default no limit 42 | ``` 43 | 44 | Use any of the above specified flags to override their default behavior. 45 | 46 | # Live reloading hooks 47 | 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. 48 | ```bash 49 | kill -USR1 webhookpid 50 | 51 | kill -HUP webhookpid 52 | ``` 53 | -------------------------------------------------------------------------------- /docs/logo/logo-1024x1024.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soulteary/webhook/ba7c4651b1a83c72ba7ac3abc28d34efd8a6d028/docs/logo/logo-1024x1024.jpg -------------------------------------------------------------------------------- /docs/logo/logo-600x600.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soulteary/webhook/ba7c4651b1a83c72ba7ac3abc28d34efd8a6d028/docs/logo/logo-600x600.jpg -------------------------------------------------------------------------------- /docs/zh-CN/CLI-ENV.md: -------------------------------------------------------------------------------- 1 | # 配置参数 2 | 3 | 程序支持两种调用方法,分别是“通过命令行参数”和“设置环境变量”。 4 | 5 | 关于命令行参数,只需要记得使用 `--help`,即可查看所有的支持设置参数。 6 | 7 | 而关于环境变量的设置,我们可以通过查看 [internal/flags/define.go](https://github.com/soulteary/webhook/blob/main/internal/flags/define.go) 中的配置项,来完成一致的程序行为设置。 8 | -------------------------------------------------------------------------------- /docs/zh-CN/Hook-Definition.md: -------------------------------------------------------------------------------- 1 | # 钩子定义 2 | 3 | 我们可以在 JSON 或者 YAML 文件中定义钩子对象。每一个有效的钩子对象都必须包含 `id` 和 `execute-command` 属性,其他属性都是可选项。 4 | 5 | ## 钩子属性 6 | 7 | * `id` - 钩子的 ID。用于创建 HTTP 地址,如:`http://yourserver:port/hooks/your-hook-id`。 8 | * `execute-command` - 钩子地址在被访问时,对应的执行命令。 9 | * `command-working-directory` - 指定执行脚本时使用的工作目录。 10 | * `response-message` - 将返回给钩子调用方的字符串。 11 | * `response-headers`- 将在 HTTP 响应中返回的响应头数据,格式为 `{"name":"X-Example-Header","value":"it works"}`。 12 | * `success-http-response-code` - 调用成功后,返回的 HTTP 状态码。 13 | * `incoming-payload-content-type` - 设置传入HTTP请求的 `Content-Type`,例如:`application/json`。 14 | * `http-methods` - 允许的 HTTP 请求方法,可以设置为 `POST` 或 `GET` 等。 15 | * `include-command-output-in-response` - 布尔值(`true`/`false`),是否应该等待脚本程序执行完毕,并将原始程序输出返回给调用方。如果程序执行失败,将会返回 `HTTP 500 程序内部错误` 的状态信息,通常会返回 `HTTP 200 OK`。 16 | * `include-command-out-in-response-on-error` - 布尔值(`true`/`false`),当命令执行失败时,是否将命令中的 `stdout` 和 `stderror` 返回给调用方。 17 | * `pass-arguments-to-command` - 将指定参数设置在 JSON 字符串中,并传递给要调用程序的参数中,你可以访问[请求值设置][Request-Values]文档,来了解详细的内容。例如,我们可以传递一个字符串内容,格式为:`{"source":"string","name":"value"}` 18 | * `parse-parameters-as-json` - 将指定参数设置在 JSON 字符串中,使用规则和`pass-arguments-to-command` 一致。 19 | * `pass-environment-to-command` - 将指定的参数设置为环境变量,并传递给调用程序的参数中。如果没有指定 `"envname"`字段,那么程序将采用 "HOOK_argumentname" (`argumentname` 具体请求参数名)变量名称,否则将使用 `"envname"` 字段作为名称。在[请求值设置][Request-Values]文档中可以了解更多细节。例如,如果要将静态字符串值传递给命令,可以将其指定为 `{"source":"string","envname":"SOMETHING","name":"value"}`。 20 | * `pass-file-to-command` - 指定要传递给命令的文件列表。传递给命令的内容将在序列化处理并存储在临时文件中(并行调用程序,将发生文件的覆盖)。如果你想在脚本中使用环境变量的方法访问文件名称,可以参考 `pass-environment-to-command`。如果你定义了 `command-working-directory`,将会作为文件的保存目录。如果额外设置了 `base64decode` 为 true,那么程序将会对接收到的二进制数据先进行 Base64 解码,再进行文件保存。默认情况下,这些文件将会在 WebHook 程序退出后被删除。更多信息可以查阅[请求值设置][Request-Values]文档。 21 | * `trigger-rule` - 配置钩子的具体触发规则,访问[钩子规则][Hook-Rules]文档,来查看详细内容。 22 | * `trigger-rule-mismatch-http-response-code` - 设置在不满足触发规则时返回给调用方的 HTTP 状态码。 23 | * `trigger-signature-soft-failures` - 设置是否允许忽略钩子触发过程中的签名验证处理结果,默认情况下,如果签名校验失败,那么会被视为程序执行出错。 24 | 25 | ## 示例 26 | 27 | 更复杂的例子,可以查看[示例][Hook-Examples]文档。 28 | 29 | 30 | [Request-Values]: ./Request-Values.md 31 | [Hook-Rules]: ./Hook-Rules.md 32 | [Hook-Examples]: ./Hook-Examples.md 33 | -------------------------------------------------------------------------------- /docs/zh-CN/Hook-Examples.md: -------------------------------------------------------------------------------- 1 | # 钩子示例 2 | 3 | 我们可以在 JSON 或者 YAML 文件中定义钩子对象,具体定义可参考[钩子定义]文档。 4 | 5 | 🌱 此页面仍在持续建议,欢迎贡献你的力量。 6 | 7 | ## 目录 8 | 9 | * [Incoming Github webhook](#incoming-github-webhook) 10 | * [Incoming Bitbucket webhook](#incoming-bitbucket-webhook) 11 | * [Incoming Gitlab webhook](#incoming-gitlab-webhook) 12 | * [Incoming Gogs webhook](#incoming-gogs-webhook) 13 | * [Incoming Gitea webhook](#incoming-gitea-webhook) 14 | * [Slack slash command](#slack-slash-command) 15 | * [A simple webhook with a secret key in GET query](#a-simple-webhook-with-a-secret-key-in-get-query) 16 | * [JIRA Webhooks](#jira-webhooks) 17 | * [Pass File-to-command sample](#pass-file-to-command-sample) 18 | * [Incoming Scalr Webhook](#incoming-scalr-webhook) 19 | * [Travis CI webhook](#travis-ci-webhook) 20 | * [XML Payload](#xml-payload) 21 | * [Multipart Form Data](#multipart-form-data) 22 | * [Pass string arguments to command](#pass-string-arguments-to-command) 23 | * [Receive Synology DSM notifications](#receive-synology-notifications) 24 | 25 | ## Incoming Github webhook 26 | 27 | ```json 28 | [ 29 | { 30 | "id": "webhook", 31 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 32 | "command-working-directory": "/home/adnan/go", 33 | "pass-arguments-to-command": 34 | [ 35 | { 36 | "source": "payload", 37 | "name": "head_commit.id" 38 | }, 39 | { 40 | "source": "payload", 41 | "name": "pusher.name" 42 | }, 43 | { 44 | "source": "payload", 45 | "name": "pusher.email" 46 | } 47 | ], 48 | "trigger-rule": 49 | { 50 | "and": 51 | [ 52 | { 53 | "match": 54 | { 55 | "type": "payload-hmac-sha1", 56 | "secret": "mysecret", 57 | "parameter": 58 | { 59 | "source": "header", 60 | "name": "X-Hub-Signature" 61 | } 62 | } 63 | }, 64 | { 65 | "match": 66 | { 67 | "type": "value", 68 | "value": "refs/heads/master", 69 | "parameter": 70 | { 71 | "source": "payload", 72 | "name": "ref" 73 | } 74 | } 75 | } 76 | ] 77 | } 78 | } 79 | ] 80 | ``` 81 | 82 | ## Incoming Bitbucket webhook 83 | 84 | 85 | Bitbucket 不会将任何密钥传递回 WebHook。[根据该产品文档](https://support.atlassian.com/organization-administration/docs/ip-addresses-and-domains-for-atlassian-cloud-products/#Outgoing-Connections),为了确保调用发起方是 Bitbucket,我们需要设置一组 IP 白名单: 86 | 87 | ```json 88 | [ 89 | { 90 | "id": "webhook", 91 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 92 | "command-working-directory": "/home/adnan/go", 93 | "pass-arguments-to-command": 94 | [ 95 | { 96 | "source": "payload", 97 | "name": "actor.username" 98 | } 99 | ], 100 | "trigger-rule": 101 | { 102 | "or": 103 | [ 104 | { "match": { "type": "ip-whitelist", "ip-range": "13.52.5.96/28" } }, 105 | { "match": { "type": "ip-whitelist", "ip-range": "13.236.8.224/28" } }, 106 | { "match": { "type": "ip-whitelist", "ip-range": "18.136.214.96/28" } }, 107 | { "match": { "type": "ip-whitelist", "ip-range": "18.184.99.224/28" } }, 108 | { "match": { "type": "ip-whitelist", "ip-range": "18.234.32.224/28" } }, 109 | { "match": { "type": "ip-whitelist", "ip-range": "18.246.31.224/28" } }, 110 | { "match": { "type": "ip-whitelist", "ip-range": "52.215.192.224/28" } }, 111 | { "match": { "type": "ip-whitelist", "ip-range": "104.192.137.240/28" } }, 112 | { "match": { "type": "ip-whitelist", "ip-range": "104.192.138.240/28" } }, 113 | { "match": { "type": "ip-whitelist", "ip-range": "104.192.140.240/28" } }, 114 | { "match": { "type": "ip-whitelist", "ip-range": "104.192.142.240/28" } }, 115 | { "match": { "type": "ip-whitelist", "ip-range": "104.192.143.240/28" } }, 116 | { "match": { "type": "ip-whitelist", "ip-range": "185.166.143.240/28" } }, 117 | { "match": { "type": "ip-whitelist", "ip-range": "185.166.142.240/28" } } 118 | ] 119 | } 120 | } 121 | ] 122 | ``` 123 | 124 | ## Incoming GitLab Webhook 125 | 126 | GitLab 提供了多种事件类型支持。可以参考下面的文档 [gitlab-ce/integrations/webhooks](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/project/integrations/webhooks.md) 来进行设置。 127 | 128 | 通过配置 `payload` 和钩子规则,来访问请求体中的数据: 129 | 130 | ```json 131 | [ 132 | { 133 | "id": "redeploy-webhook", 134 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 135 | "command-working-directory": "/home/adnan/go", 136 | "pass-arguments-to-command": 137 | [ 138 | { 139 | "source": "payload", 140 | "name": "user_name" 141 | } 142 | ], 143 | "response-message": "Executing redeploy script", 144 | "trigger-rule": 145 | { 146 | "match": 147 | { 148 | "type": "value", 149 | "value": "", 150 | "parameter": 151 | { 152 | "source": "header", 153 | "name": "X-Gitlab-Token" 154 | } 155 | } 156 | } 157 | } 158 | ] 159 | ``` 160 | 161 | ## Incoming Gogs webhook 162 | 163 | ```json 164 | [ 165 | { 166 | "id": "webhook", 167 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 168 | "command-working-directory": "/home/adnan/go", 169 | "pass-arguments-to-command": 170 | [ 171 | { 172 | "source": "payload", 173 | "name": "head_commit.id" 174 | }, 175 | { 176 | "source": "payload", 177 | "name": "pusher.name" 178 | }, 179 | { 180 | "source": "payload", 181 | "name": "pusher.email" 182 | } 183 | ], 184 | "trigger-rule": 185 | { 186 | "and": 187 | [ 188 | { 189 | "match": 190 | { 191 | "type": "payload-hmac-sha256", 192 | "secret": "mysecret", 193 | "parameter": 194 | { 195 | "source": "header", 196 | "name": "X-Gogs-Signature" 197 | } 198 | } 199 | }, 200 | { 201 | "match": 202 | { 203 | "type": "value", 204 | "value": "refs/heads/master", 205 | "parameter": 206 | { 207 | "source": "payload", 208 | "name": "ref" 209 | } 210 | } 211 | } 212 | ] 213 | } 214 | } 215 | ] 216 | ``` 217 | 218 | ## Incoming Gitea webhook 219 | 220 | ```json 221 | [ 222 | { 223 | "id": "webhook", 224 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 225 | "command-working-directory": "/home/adnan/go", 226 | "pass-arguments-to-command": 227 | [ 228 | { 229 | "source": "payload", 230 | "name": "head_commit.id" 231 | }, 232 | { 233 | "source": "payload", 234 | "name": "pusher.name" 235 | }, 236 | { 237 | "source": "payload", 238 | "name": "pusher.email" 239 | } 240 | ], 241 | "trigger-rule": 242 | { 243 | "and": 244 | [ 245 | { 246 | "match": 247 | { 248 | "type": "value", 249 | "value": "mysecret", 250 | "parameter": 251 | { 252 | "source": "payload", 253 | "name": "secret" 254 | } 255 | } 256 | }, 257 | { 258 | "match": 259 | { 260 | "type": "value", 261 | "value": "refs/heads/master", 262 | "parameter": 263 | { 264 | "source": "payload", 265 | "name": "ref" 266 | } 267 | } 268 | } 269 | ] 270 | } 271 | } 272 | ] 273 | ``` 274 | 275 | ## Slack slash command 276 | 277 | ```json 278 | [ 279 | { 280 | "id": "redeploy-webhook", 281 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 282 | "command-working-directory": "/home/adnan/go", 283 | "response-message": "Executing redeploy script", 284 | "trigger-rule": 285 | { 286 | "match": 287 | { 288 | "type": "value", 289 | "value": "", 290 | "parameter": 291 | { 292 | "source": "payload", 293 | "name": "token" 294 | } 295 | } 296 | } 297 | } 298 | ] 299 | ``` 300 | 301 | ## A simple webhook with a secret key in GET query 302 | 303 | __因为安全性比较低,不推荐在生产环境使用__ 304 | 305 | `example.com:9000/hooks/simple-one` - 将不会被调用 306 | `example.com:9000/hooks/simple-one?token=42` - 将会被调用 307 | 308 | ```json 309 | [ 310 | { 311 | "id": "simple-one", 312 | "execute-command": "/path/to/command.sh", 313 | "response-message": "Executing simple webhook...", 314 | "trigger-rule": 315 | { 316 | "match": 317 | { 318 | "type": "value", 319 | "value": "42", 320 | "parameter": 321 | { 322 | "source": "url", 323 | "name": "token" 324 | } 325 | } 326 | } 327 | } 328 | ] 329 | ``` 330 | 331 | ## JIRA Webhooks 332 | 333 | [来自网友 @perfecto25 的教程](https://sites.google.com/site/mrxpalmeiras/more/jira-webhooks) 334 | 335 | ## Pass File-to-command sample 336 | 337 | ### Webhook configuration 338 | 339 | ```json 340 | [ 341 | { 342 | "id": "test-file-webhook", 343 | "execute-command": "/bin/ls", 344 | "command-working-directory": "/tmp", 345 | "pass-file-to-command": 346 | [ 347 | { 348 | "source": "payload", 349 | "name": "binary", 350 | "envname": "ENV_VARIABLE", // to use $ENV_VARIABLE in execute-command 351 | // if not defined, $HOOK_BINARY will be provided 352 | "base64decode": true, // defaults to false 353 | } 354 | ], 355 | "include-command-output-in-response": true 356 | } 357 | ] 358 | ``` 359 | 360 | ### Sample client usage 361 | 362 | 将下面的内容保存为 `testRequest.json` 文件: 363 | 364 | ```json 365 | {"binary":"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA2lpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wUmlnaHRzPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvcmlnaHRzLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcFJpZ2h0czpNYXJrZWQ9IkZhbHNlIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjEzMTA4RDI0QzMxQjExRTBCMzYzRjY1QUQ1Njc4QzFBIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjEzMTA4RDIzQzMxQjExRTBCMzYzRjY1QUQ1Njc4QzFBIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDUzMgV2luZG93cyI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ1dWlkOkFDMUYyRTgzMzI0QURGMTFBQUI4QzUzOTBEODVCNUIzIiBzdFJlZjpkb2N1bWVudElEPSJ1dWlkOkM5RDM0OTY2NEEzQ0REMTFCMDhBQkJCQ0ZGMTcyMTU2Ii8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+IBFgEwAAAmJJREFUeNqkk89rE1EQx2d/NNq0xcYYayPYJDWC9ODBsKIgAREjBmvEg2cvHnr05KHQ9iB49SL+/BMEfxBQKHgwCEbTNNIYaqgaoanFJi+rcXezye4689jYkIMIDnx47837zrx583YFx3Hgf0xA6/dJyAkkgUy4vgryAnmNWH9L4EVmotFoKplMHgoGg6PkrFarjXQ6/bFcLj/G5W1E+3NaX4KZeDx+dX5+7kg4HBlmrC6JoiDFYrGhROLM/mp1Y6JSqdCd3/SW0GUqEAjkl5ZyHTSHKBQKnO6a9khD2m5cr91IJBJ1VVWdiM/n6LruNJtNDs3JR3ukIW03SHTHi8iVsbG9I51OG1bW16HVasHQZopDc/JZVgdIQ1o3BmTkEnJXURS/KIpgGAYPkCQJPi0u8uzDKQN0XQPbtgE1MmrHs9nsfSqAEjxCNtHxZHLy4G4smUQgyzL4LzOegDGGp1ucVqsNqKVrpJCM7F4hg6iaZvhqtZrg8XjA4xnAU3XeKLqWaRImoIZeQXVjQO5pYp4xNVirsR1erxer2O4yfa227WCwhtWoJmn7m0h270NxmemFW4706zMm8GCgxBGEASCfhnukIW03iFdQnOPz0LNKp3362JqQzSw4u2LXBe+Bs3xD+/oc1NxN55RiC9fOme0LEQiRf2rBzaKEeJJ37ZWTVunBeGN2WmQjg/DeLTVP89nzAive2dMwlo9bpFVC2xWMZr+A720FVn88fAUb3wDMOjyN7YNc6TvUSHQ4AH6TOUdLL7em68UtWPsJqxgTpgeiLu1EBt1R+Me/mF7CQPTfAgwAGxY2vOTrR3oAAAAASUVORK5CYII="} 366 | ``` 367 | 368 | 使用 `curl` 来执行携带上面数据的访问请求: 369 | 370 | ```bash 371 | #!/bin/bash 372 | curl -H "Content-Type:application/json" -X POST -d @testRequest.json \ 373 | http://localhost:9000/hooks/test-file-webhook 374 | ``` 375 | 376 | 或者,你也可以使用 [jpmens/jo](https://github.com/jpmens/jo) 这个工具,在一行命令中展开 JSON 的数据,传递给 WebHook: 377 | 378 | ```bash 379 | jo binary=%filename.zip | curl -H "Content-Type:application/json" -X POST -d @- \ 380 | http://localhost:9000/hooks/test-file-webhook 381 | ``` 382 | 383 | ## Incoming Scalr Webhook 384 | 385 | Scalr 根据具体事件(如主机启动、宕机) 向我们配置的 WebHook URL 端点发起请求调用,告知事件的发生。 386 | 387 | Scalr 会为每个配置的钩子地址分配一个唯一的签名密钥。 388 | 389 | 你可以参考这个文档来了解[如何在 Scalr 中配置网络钩子](https://scalr-wiki.atlassian.net/wiki/spaces/docs/pages/6193173/Webhooks。想要使用 Scalr,我们需要配置钩子匹配规则,将匹配类型设置为 `"scalr-signature"`。 390 | 391 | [来自网友 @hassanbabaie 的教程] 392 | 393 | ```json 394 | [ 395 | { 396 | "id": "redeploy-webhook", 397 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 398 | "command-working-directory": "/home/adnan/go", 399 | "include-command-output-in-response": true, 400 | "trigger-rule": 401 | { 402 | "match": 403 | { 404 | "type": "scalr-signature", 405 | "secret": "Scalr-provided signing key" 406 | } 407 | }, 408 | "pass-environment-to-command": 409 | [ 410 | { 411 | "envname": "EVENT_NAME", 412 | "source": "payload", 413 | "name": "eventName" 414 | }, 415 | { 416 | "envname": "SERVER_HOSTNAME", 417 | "source": "payload", 418 | "name": "data.SCALR_SERVER_HOSTNAME" 419 | } 420 | ] 421 | } 422 | ] 423 | 424 | ``` 425 | 426 | ## Travis CI webhook 427 | 428 | Travis 会以 `payload=` 的形式向 WebHook 发送消息。所以,我们需要解析 JSON 中的数据: 429 | 430 | ```json 431 | [ 432 | { 433 | "id": "deploy", 434 | "execute-command": "/root/my-server/deployment.sh", 435 | "command-working-directory": "/root/my-server", 436 | "parse-parameters-as-json": [ 437 | { 438 | "source": "payload", 439 | "name": "payload" 440 | } 441 | ], 442 | "trigger-rule": 443 | { 444 | "and": 445 | [ 446 | { 447 | "match": 448 | { 449 | "type": "value", 450 | "value": "passed", 451 | "parameter": { 452 | "name": "payload.state", 453 | "source": "payload" 454 | } 455 | } 456 | }, 457 | { 458 | "match": 459 | { 460 | "type": "value", 461 | "value": "master", 462 | "parameter": { 463 | "name": "payload.branch", 464 | "source": "payload" 465 | } 466 | } 467 | } 468 | ] 469 | } 470 | } 471 | ] 472 | ``` 473 | 474 | ## JSON Array Payload 475 | 476 | 如果 JSON 内容是一个数组而非对象,WebHook 将解析该数据并将其放入一个名为 `root.` 的对象中。 477 | 478 | 所以,当我们访问具体数据时,需要使用 `root.` 开头的语法来访问数据。 479 | 480 | ```json 481 | [ 482 | { 483 | "email": "example@test.com", 484 | "timestamp": 1513299569, 485 | "smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>", 486 | "event": "processed", 487 | "category": "cat facts", 488 | "sg_event_id": "sg_event_id", 489 | "sg_message_id": "sg_message_id" 490 | }, 491 | { 492 | "email": "example@test.com", 493 | "timestamp": 1513299569, 494 | "smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>", 495 | "event": "deferred", 496 | "category": "cat facts", 497 | "sg_event_id": "sg_event_id", 498 | "sg_message_id": "sg_message_id", 499 | "response": "400 try again later", 500 | "attempt": "5" 501 | } 502 | ] 503 | ``` 504 | 505 | 访问数组中的第二个元素,我们可以这样: 506 | 507 | ```json 508 | [ 509 | { 510 | "id": "sendgrid", 511 | "execute-command": "{{ .Hookecho }}", 512 | "trigger-rule": { 513 | "match": { 514 | "type": "value", 515 | "parameter": { 516 | "source": "payload", 517 | "name": "root.1.event" 518 | }, 519 | "value": "deferred" 520 | } 521 | } 522 | } 523 | ] 524 | ``` 525 | 526 | ## XML Payload 527 | 528 | 假设我们要处理的 XML 数据如下: 529 | 530 | ```xml 531 | 532 | 533 | 534 | 535 | 536 | 537 | Hello!! 538 | 539 | 540 | ``` 541 | 542 | ```json 543 | [ 544 | { 545 | "id": "deploy", 546 | "execute-command": "/root/my-server/deployment.sh", 547 | "command-working-directory": "/root/my-server", 548 | "trigger-rule": { 549 | "and": [ 550 | { 551 | "match": { 552 | "type": "value", 553 | "parameter": { 554 | "source": "payload", 555 | "name": "app.users.user.0.-name" 556 | }, 557 | "value": "Jeff" 558 | } 559 | }, 560 | { 561 | "match": { 562 | "type": "value", 563 | "parameter": { 564 | "source": "payload", 565 | "name": "app.messages.message.#text" 566 | }, 567 | "value": "Hello!!" 568 | } 569 | }, 570 | ], 571 | } 572 | } 573 | ] 574 | ``` 575 | 576 | ## Multipart Form Data 577 | 578 | 下面是 [Plex Media Server webhook](https://support.plex.tv/articles/115002267687-webhooks/) 的数据示例。 579 | 580 | Plex Media Server 在调用 WebHook 时,将发送两类数据:payload 和 thumb,我们只需要关心 payload 部分。 581 | 582 | ```json 583 | [ 584 | { 585 | "id": "plex", 586 | "execute-command": "play-command.sh", 587 | "parse-parameters-as-json": [ 588 | { 589 | "source": "payload", 590 | "name": "payload" 591 | } 592 | ], 593 | "trigger-rule": 594 | { 595 | "match": 596 | { 597 | "type": "value", 598 | "parameter": { 599 | "source": "payload", 600 | "name": "payload.event" 601 | }, 602 | "value": "media.play" 603 | } 604 | } 605 | } 606 | ] 607 | ``` 608 | 609 | 包含多个部分的表单数据体,每个部分都将有一个 `Content-Disposition` 头,例如: 610 | 611 | ``` 612 | Content-Disposition: form-data; name="payload" 613 | Content-Disposition: form-data; name="thumb"; filename="thumb.jpg" 614 | ``` 615 | 616 | 我们根据 `Content-Disposition` 值中的 `name` 属性来区分不同部分。 617 | 618 | ## Pass string arguments to command 619 | 620 | 想要将简单的字符串作为参数传递给需要执行的命令,需要使用 `string` 参数。 621 | 622 | 下面的例子中,我们将在传递数据 `pusher.email` 之前,将两个字符串 (`-e` 和 `123123`) 传递给 `execute-command`: 623 | 624 | ```json 625 | [ 626 | { 627 | "id": "webhook", 628 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 629 | "command-working-directory": "/home/adnan/go", 630 | "pass-arguments-to-command": 631 | [ 632 | { 633 | "source": "string", 634 | "name": "-e" 635 | }, 636 | { 637 | "source": "string", 638 | "name": "123123" 639 | }, 640 | { 641 | "source": "payload", 642 | "name": "pusher.email" 643 | } 644 | ] 645 | } 646 | ] 647 | ``` 648 | 649 | ## Receive Synology DSM notifications 650 | 651 | 我们可以通过 WebHook 安全的接收来自群晖的推送通知。 652 | 653 | 尽管 DSM 7.x 中引入的 Webhook 功能似乎因为功能不完整,存在使用问题。但是我们可以使用 Synology SMS 通知服务来完成 WebHook 请求调用。想要要在 DSM 上配置 SMS 通知,可以参考下面的文档 [ryancurrah/synology-notifications](https://github.com/ryancurrah/synology-notifications)。使用这个方案,我们将可以在 WebHook 中接受任何来自群会发送的通知内容。 654 | 655 | 在设置的过程中,我们需要指定一个 `api_key`,你可以随便生成一个 32 个字符长度的内容,来启用身份验证机制,来保护 WebHook 的执行。 656 | 657 | 除此之外,我们还可以在群晖的“控制面板 - 通知 - 规则”中配置想要接收的通知类型。 658 | 659 | ```json 660 | [ 661 | { 662 | "id": "synology", 663 | "execute-command": "do-something.sh", 664 | "command-working-directory": "/opt/webhook-linux-amd64/synology", 665 | "response-message": "Request accepted", 666 | "pass-arguments-to-command": 667 | [ 668 | { 669 | "source": "payload", 670 | "name": "message" 671 | } 672 | ], 673 | "trigger-rule": 674 | { 675 | "match": 676 | { 677 | "type": "value", 678 | "value": "PUT_YOUR_API_KEY_HERE", 679 | "parameter": 680 | { 681 | "source": "header", 682 | "name": "api_key" 683 | } 684 | } 685 | } 686 | } 687 | ] 688 | ``` 689 | 690 | [Hook-Definition]: ./Hook-Definition.md 691 | -------------------------------------------------------------------------------- /docs/zh-CN/Hook-Rules.md: -------------------------------------------------------------------------------- 1 | # 钩子匹配规则 2 | 3 | 钩子的匹配规则包含一些逻辑性处理和签名校验方法。 4 | 5 | ## 支持设置的规则列表 6 | 7 | * [与逻辑](#and) 8 | * [或逻辑](#or) 9 | * [非逻辑](#not) 10 | * [组合使用](#multi-level) 11 | * [匹配逻辑](#match) 12 | * [数值匹配](#match-value) 13 | * [正则匹配](#match-regex) 14 | * [请求内容 hmac-sha1 签名校验](#match-payload-hmac-sha1) 15 | * [请求内容 hmac-sha256 签名校验](#match-payload-hmac-sha256) 16 | * [请求内容 hmac-sha512 签名校验](#match-payload-hmac-sha512) 17 | * [IP 白名单](#match-whitelisted-ip-range) 18 | * [scalr 签名校验](#match-scalr-signature) 19 | 20 | ## And 21 | 22 | 当且仅当,所有子规则结果都为 `true`,才会执行钩子。 23 | 24 | ```json 25 | { 26 | "and": 27 | [ 28 | { 29 | "match": 30 | { 31 | "type": "value", 32 | "value": "refs/heads/master", 33 | "parameter": 34 | { 35 | "source": "payload", 36 | "name": "ref" 37 | } 38 | } 39 | }, 40 | { 41 | "match": 42 | { 43 | "type": "regex", 44 | "regex": ".*", 45 | "parameter": 46 | { 47 | "source": "payload", 48 | "name": "repository.owner.name" 49 | } 50 | } 51 | } 52 | ] 53 | } 54 | ``` 55 | 56 | ## OR 57 | 58 | 当任何子规则结果为 `true` 时,才会执行钩子。 59 | 60 | ```json 61 | { 62 | "or": 63 | [ 64 | { 65 | "match": 66 | { 67 | "type": "value", 68 | "value": "refs/heads/master", 69 | "parameter": 70 | { 71 | "source": "payload", 72 | "name": "ref" 73 | } 74 | } 75 | }, 76 | { 77 | "match": 78 | { 79 | "type": "value", 80 | "value": "refs/heads/development", 81 | "parameter": 82 | { 83 | "source": "payload", 84 | "name": "ref" 85 | } 86 | } 87 | } 88 | ] 89 | } 90 | ``` 91 | 92 | ## Not 93 | 94 | 当且仅当子规则结果都为 `false` 时,才会执行钩子。 95 | 96 | ```json 97 | { 98 | "not": 99 | { 100 | "match": 101 | { 102 | "type": "value", 103 | "value": "refs/heads/development", 104 | "parameter": 105 | { 106 | "source": "payload", 107 | "name": "ref" 108 | } 109 | } 110 | } 111 | } 112 | ``` 113 | 114 | ## Multi-level 115 | 116 | ```json 117 | { 118 | "and": [ 119 | { 120 | "match": { 121 | "parameter": { 122 | "source": "header", 123 | "name": "X-Hub-Signature" 124 | }, 125 | "type": "payload-hmac-sha1", 126 | "secret": "mysecret" 127 | } 128 | }, 129 | { 130 | "or": [ 131 | { 132 | "match": 133 | { 134 | "parameter": 135 | { 136 | "source": "payload", 137 | "name": "ref" 138 | }, 139 | "type": "value", 140 | "value": "refs/heads/master" 141 | } 142 | }, 143 | { 144 | "match": 145 | { 146 | "parameter": 147 | { 148 | "source": "header", 149 | "name": "X-GitHub-Event" 150 | }, 151 | "type": "value", 152 | "value": "ping" 153 | } 154 | } 155 | ] 156 | } 157 | ] 158 | } 159 | ``` 160 | 161 | ## Match 162 | 163 | 当且仅当 `parameter` 字段中的数值满足 `type` 指定规则时,才会执行钩子。 164 | 165 | *注意* 匹配规则中的 `数值类型` 和 `布尔类型` 的值需要使用引号引起来,作为字符串传递。 166 | 167 | ### Match value 168 | 169 | ```json 170 | { 171 | "match": 172 | { 173 | "type": "value", 174 | "value": "refs/heads/development", 175 | "parameter": 176 | { 177 | "source": "payload", 178 | "name": "ref" 179 | } 180 | } 181 | } 182 | ``` 183 | 184 | ### Match regex 185 | 186 | 正则表达式的语法,可以参考 [Golang Regexp Syntax](http://golang.org/pkg/regexp/syntax/) 187 | 188 | ```json 189 | { 190 | "match": 191 | { 192 | "type": "regex", 193 | "regex": ".*", 194 | "parameter": 195 | { 196 | "source": "payload", 197 | "name": "ref" 198 | } 199 | } 200 | } 201 | ``` 202 | 203 | ### Match payload-hmac-sha1 204 | 205 | 使用 SHA1 哈希和指定的的 *secret* 字段验证提交数据的 HMAC 签名有效: 206 | 207 | ```json 208 | { 209 | "match": 210 | { 211 | "type": "payload-hmac-sha1", 212 | "secret": "yoursecret", 213 | "parameter": 214 | { 215 | "source": "header", 216 | "name": "X-Hub-Signature" 217 | } 218 | } 219 | } 220 | ``` 221 | 222 | 注意,你可以使用逗号分隔字符串,来传递多个签名。程序将尝试匹配所有的签名,找到任意一项匹配内容。 223 | 224 | ```yaml 225 | X-Hub-Signature: sha1=the-first-signature,sha1=the-second-signature 226 | ``` 227 | 228 | ### Match payload-hmac-sha256 229 | 230 | 使用 SHA256 哈希和指定的的 *secret* 字段验证提交数据的 HMAC 签名有效: 231 | 232 | ```json 233 | { 234 | "match": 235 | { 236 | "type": "payload-hmac-sha256", 237 | "secret": "yoursecret", 238 | "parameter": 239 | { 240 | "source": "header", 241 | "name": "X-Signature" 242 | } 243 | } 244 | } 245 | ``` 246 | 247 | 注意,你可以使用逗号分隔字符串,来传递多个签名。程序将尝试匹配所有的签名,找到任意一项匹配内容。 248 | 249 | ```yaml 250 | X-Hub-Signature: sha256=the-first-signature,sha256=the-second-signature 251 | ``` 252 | 253 | ### Match payload-hmac-sha512 254 | 255 | 使用 SHA512 哈希和指定的的 *secret* 字段验证提交数据的 HMAC 签名有效: 256 | 257 | ```json 258 | { 259 | "match": 260 | { 261 | "type": "payload-hmac-sha512", 262 | "secret": "yoursecret", 263 | "parameter": 264 | { 265 | "source": "header", 266 | "name": "X-Signature" 267 | } 268 | } 269 | } 270 | ``` 271 | 272 | 注意,你可以使用逗号分隔字符串,来传递多个签名。程序将尝试匹配所有的签名,找到任意一项匹配内容。 273 | 274 | ```yaml 275 | X-Hub-Signature: sha512=the-first-signature,sha512=the-second-signature 276 | ``` 277 | 278 | ### Match Whitelisted IP range 279 | 280 | 支持使用 IPv4 或 IPv6 格式的地址搭配[CIDR表示法](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_blocks),来表达有效的 IP 范围。 281 | 282 | 如果想要匹配单个 IP 地址,请在 IP 后添加 `/32`。 283 | 284 | ```json 285 | { 286 | "match": 287 | { 288 | "type": "ip-whitelist", 289 | "ip-range": "192.168.0.1/24" 290 | } 291 | } 292 | ``` 293 | 294 | ### Match scalr-signature 295 | 296 | 验证是否是有效的 scalr 签名,以及请求是在五分钟内收到的未过期请求。你可以在 Scalr 中为每一个 WebHook URL 生成唯一的签名密钥。 297 | 298 | 因为校验方法和时间相关,请确保你的 Scalr 和 WebHook 服务器都设置了 NTP 服务,时间一致。 299 | 300 | ```json 301 | { 302 | "match": 303 | { 304 | "type": "scalr-signature", 305 | "secret": "Scalr-provided signing key" 306 | } 307 | } 308 | ``` 309 | -------------------------------------------------------------------------------- /docs/zh-CN/Request-Values.md: -------------------------------------------------------------------------------- 1 | # 请求内容设置 2 | 3 | 程序支持设置四种类型的请求内容: 4 | 5 | - HTTP 请求头 6 | - HTTP 查询参数 7 | - HTTP 请求参数 8 | - HTTP 请求体内容 9 | 10 | ## HTTP 请求头 11 | 12 | ```json 13 | { 14 | "source": "header", 15 | "name": "Header-Name" 16 | } 17 | ``` 18 | 19 | ## HTTP 查询参数 20 | 21 | ```json 22 | { 23 | "source": "url", 24 | "name": "parameter-name" 25 | } 26 | ``` 27 | 28 | ## HTTP 请求参数 29 | 30 | ```json 31 | { 32 | "source": "request", 33 | "name": "method" 34 | } 35 | ``` 36 | 37 | ```json 38 | { 39 | "source":"request", 40 | "name":"remote-addr" 41 | } 42 | ``` 43 | 44 | ## HTTP 请求体内容(JSON / XML / 表单内容) 45 | 46 | ```json 47 | { 48 | "source": "payload", 49 | "name": "parameter-name" 50 | } 51 | ``` 52 | 53 | ### JSON 54 | 55 | 我们可以使用类似下面的方式来设置 JSON 请求数据。 56 | 57 | ```json 58 | { 59 | "commits": [ 60 | { 61 | "commit": { 62 | "id": 1 63 | } 64 | }, 65 | { 66 | "commit": { 67 | "id": 2 68 | } 69 | } 70 | ] 71 | } 72 | ``` 73 | 74 | 如果我们想在程序中获得 “第一个 commit 提交” 数据的 ID,可以这样: 75 | 76 | ```json 77 | { 78 | "source": "payload", 79 | "name": "commits.0.commit.id" 80 | } 81 | ``` 82 | 83 | 如果 JSON 中包含我们访问的 Key,例如 `{ "commits.0.commit.id": "value", ... }`。那么程序将会优先访问这个数据,而非展开具体的对象,解析其中的数据。 84 | 85 | ### XML有效负载 86 | 87 | 使用 XML 作为数据类似上面的 JSON 数据使用,但是相对更复杂一些。以下面的 XML 数据为例: 88 | 89 | ```xml 90 | 91 | 92 | 93 | 94 | 95 | 96 | Nice To Meet U!! 97 | 98 | 99 | ``` 100 | 101 | 如果我们想要访问 `user` 元素,我们需要将其转换为数组,而不能使用 JSON 中的访问方式。 102 | 103 | 在 XML 中,`app.users.user.0.name` 将得到 `Li Lei`;因为只有一个 `message` 元素,所以解析的时候不会作为数组处理。`app.messages.message.id` 的结果是 `1`。如果想要访问 `message` 的标签文本,那么我们需要使用 `app.messages.message.#text`。 104 | 105 | ## 环境变量使用 106 | 107 | 当我们想使用 `envname` 属性,来设置命令使用的环境变量名称时: 108 | 109 | ```json 110 | { 111 | "source":"url", 112 | "name":"q", 113 | "envname":"QUERY" 114 | } 115 | ``` 116 | 117 | 上面的例子中,我们设置了一个环境变量 `QUERY`,对应的是请求 WebHook 的查询字符串中的查询参数 `q`。 118 | 119 | ## 特殊情况 120 | 121 | 如果你想不对 JSON 进行解析,将完整的 JSON 传递给命令,可以使用下面的方法: 122 | 123 | ```json 124 | { 125 | "source": "entire-payload" 126 | } 127 | ``` 128 | 129 | 类似的,HTTP 请求头可以用下面的方法: 130 | 131 | ```json 132 | { 133 | "source": "entire-headers" 134 | } 135 | ``` 136 | 137 | 查询变量,可以使用下面的方: 138 | 139 | ```json 140 | { 141 | "source": "entire-query" 142 | } 143 | ``` 144 | -------------------------------------------------------------------------------- /docs/zh-CN/Templates.md: -------------------------------------------------------------------------------- 1 | # 配置模版 2 | 3 | 当我们使用 `-template` [CLI 参数][CLI-ENV] 时,可以将启用将配置文件解析为 Go 模版的功能。除了支持[Go 模板内置的函数和特性][Go-Template] 之外,程序还额外提供了一个 `getenv` 的模板函数,能够将各种环境变量注入到配置文件中。 4 | 5 | ## 使用示例 6 | 7 | 在下面的 JSON 示例文件中(YAML同理),使用了 `payload-hmac-sha1` 匹配规则来选择性执行钩子程序。其中 HMAC 密钥使用了 `getenv` 函数来从环境变量中获取。 8 | 9 | 除此之外,还通过了 Go 模版内置的 `js` 和管道传书语法来确保输出的结果是 `JavaScript / JSON` 字符串。 10 | 11 | ```json 12 | [ 13 | { 14 | "id": "webhook", 15 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 16 | "command-working-directory": "/home/adnan/go", 17 | "response-message": "I got the payload!", 18 | "response-headers": 19 | [ 20 | { 21 | "name": "Access-Control-Allow-Origin", 22 | "value": "*" 23 | } 24 | ], 25 | "pass-arguments-to-command": 26 | [ 27 | { 28 | "source": "payload", 29 | "name": "head_commit.id" 30 | }, 31 | { 32 | "source": "payload", 33 | "name": "pusher.name" 34 | }, 35 | { 36 | "source": "payload", 37 | "name": "pusher.email" 38 | } 39 | ], 40 | "trigger-rule": 41 | { 42 | "and": 43 | [ 44 | { 45 | "match": 46 | { 47 | "type": "payload-hmac-sha1", 48 | "secret": "{{ getenv "XXXTEST_SECRET" | js }}", 49 | "parameter": 50 | { 51 | "source": "header", 52 | "name": "X-Hub-Signature" 53 | } 54 | } 55 | }, 56 | { 57 | "match": 58 | { 59 | "type": "value", 60 | "value": "refs/heads/master", 61 | "parameter": 62 | { 63 | "source": "payload", 64 | "name": "ref" 65 | } 66 | } 67 | } 68 | ] 69 | } 70 | } 71 | ] 72 | 73 | ``` 74 | 75 | [CLI-ENV]: ./CLI-ENV.md 76 | [Go-Template]: [./Request-Values.md](https://golang.org/pkg/text/template/) 77 | -------------------------------------------------------------------------------- /example/configs/hooks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "webhook", 4 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 5 | "command-working-directory": "/home/adnan/go", 6 | "response-message": "I got the payload!", 7 | "response-headers": 8 | [ 9 | { 10 | "name": "Access-Control-Allow-Origin", 11 | "value": "*" 12 | } 13 | ], 14 | "pass-arguments-to-command": 15 | [ 16 | { 17 | "source": "payload", 18 | "name": "head_commit.id" 19 | }, 20 | { 21 | "source": "payload", 22 | "name": "pusher.name" 23 | }, 24 | { 25 | "source": "payload", 26 | "name": "pusher.email" 27 | } 28 | ], 29 | "trigger-rule": 30 | { 31 | "and": 32 | [ 33 | { 34 | "match": 35 | { 36 | "type": "payload-hmac-sha1", 37 | "secret": "mysecret", 38 | "parameter": 39 | { 40 | "source": "header", 41 | "name": "X-Hub-Signature" 42 | } 43 | } 44 | }, 45 | { 46 | "match": 47 | { 48 | "type": "value", 49 | "value": "refs/heads/master", 50 | "parameter": 51 | { 52 | "source": "payload", 53 | "name": "ref" 54 | } 55 | } 56 | } 57 | ] 58 | } 59 | } 60 | ] 61 | -------------------------------------------------------------------------------- /example/configs/hooks.json.tmpl: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "webhook", 4 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 5 | "command-working-directory": "/home/adnan/go", 6 | "response-message": "I got the payload!", 7 | "response-headers": 8 | [ 9 | { 10 | "name": "Access-Control-Allow-Origin", 11 | "value": "*" 12 | } 13 | ], 14 | "pass-arguments-to-command": 15 | [ 16 | { 17 | "source": "payload", 18 | "name": "head_commit.id" 19 | }, 20 | { 21 | "source": "payload", 22 | "name": "pusher.name" 23 | }, 24 | { 25 | "source": "payload", 26 | "name": "pusher.email" 27 | } 28 | ], 29 | "trigger-rule": 30 | { 31 | "and": 32 | [ 33 | { 34 | "match": 35 | { 36 | "type": "payload-hmac-sha1", 37 | "secret": "{{ getenv "XXXTEST_SECRET" | js }}", 38 | "parameter": 39 | { 40 | "source": "header", 41 | "name": "X-Hub-Signature" 42 | } 43 | } 44 | }, 45 | { 46 | "match": 47 | { 48 | "type": "value", 49 | "value": "refs/heads/master", 50 | "parameter": 51 | { 52 | "source": "payload", 53 | "name": "ref" 54 | } 55 | } 56 | } 57 | ] 58 | } 59 | } 60 | ] 61 | -------------------------------------------------------------------------------- /example/configs/hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: webhook 2 | execute-command: /home/adnan/redeploy-go-webhook.sh 3 | command-working-directory: /home/adnan/go 4 | response-message: I got the payload! 5 | response-headers: 6 | - name: Access-Control-Allow-Origin 7 | value: '*' 8 | pass-arguments-to-command: 9 | - source: payload 10 | name: head_commit.id 11 | - source: payload 12 | name: pusher.name 13 | - source: payload 14 | name: pusher.email 15 | trigger-rule: 16 | and: 17 | - match: 18 | type: payload-hmac-sha1 19 | secret: mysecret 20 | parameter: 21 | source: header 22 | name: X-Hub-Signature 23 | - match: 24 | type: value 25 | value: refs/heads/master 26 | parameter: 27 | source: payload 28 | name: ref 29 | -------------------------------------------------------------------------------- /example/configs/hooks.yaml.tmpl: -------------------------------------------------------------------------------- 1 | - id: webhook 2 | execute-command: /home/adnan/redeploy-go-webhook.sh 3 | command-working-directory: /home/adnan/go 4 | response-message: I got the payload! 5 | response-headers: 6 | - name: Access-Control-Allow-Origin 7 | value: '*' 8 | pass-arguments-to-command: 9 | - source: payload 10 | name: head_commit.id 11 | - source: payload 12 | name: pusher.name 13 | - source: payload 14 | name: pusher.email 15 | trigger-rule: 16 | and: 17 | - match: 18 | type: payload-hmac-sha1 19 | secret: "{{ getenv "XXXTEST_SECRET" | js }}" 20 | parameter: 21 | source: header 22 | name: X-Hub-Signature 23 | - match: 24 | type: value 25 | value: refs/heads/master 26 | parameter: 27 | source: payload 28 | name: ref 29 | -------------------------------------------------------------------------------- /example/lark/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # set version 2 to compatible and adaptable to more environments 2 | version: '2' 3 | 4 | services: 5 | 6 | webhook: 7 | image: soulteary/webhook:extend-3.6.3 8 | ports: 9 | - 9000:9000 10 | environment: 11 | # The docker environment must be 12 | # set to 0.0.0.0 to allow external access 13 | HOST: "0.0.0.0" 14 | PORT: 9000 15 | # Hot reload mode can be enabled to 16 | # start the service when there is no configuration 17 | VERBOSE: true 18 | # set the hook configuration file to use 19 | HOOKS: "/app/hook-lark.yaml" 20 | # Other configurable parameters... 21 | DEBUG: true 22 | # NO_PANIC: false 23 | # LOG_PATH: "" 24 | # HOT_RELOAD: false 25 | # URL_PREFIX: "" 26 | # TEMPLATE: false 27 | # HTTP_METHODS: "" 28 | # X_REQUEST_ID: false 29 | # set i18N (WIP) 30 | # LANGUAGE: zh-CN 31 | volumes: 32 | - ./hook-lark.yaml:/app/hook-lark.yaml 33 | - ./send-lark-message.sh:/app/send-lark-message.sh 34 | -------------------------------------------------------------------------------- /example/lark/hook-lark.yaml: -------------------------------------------------------------------------------- 1 | - id: lark 2 | execute-command: ./send-lark-message.sh 3 | command-working-directory: /app 4 | include-command-output-in-response: true 5 | include-command-out-in-response-on-error: true 6 | pass-environment-to-command: 7 | - source: url 8 | name: text 9 | envname: TEXT 10 | -------------------------------------------------------------------------------- /example/lark/send-lark-message.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -n "$TEXT" ]; then 4 | curl -X POST -H "Content-Type: application/json" --data {\"msg_type\":\"text\",\"content\":{\"text\":\"$TEXT\"}} \ 5 | https://open.feishu.cn/open-apis/bot/v2/hook/6dca9854-381a-4bb9-a87b-33a222833e04 6 | echo "Send message successfully". 7 | else 8 | echo "TEXT is empty" 9 | fi 10 | -------------------------------------------------------------------------------- /example/muti-webhook/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # set version 2 to compatible and adaptable to more environments 2 | version: '2' 3 | 4 | services: 5 | 6 | webhook: 7 | image: soulteary/webhook:extend-3.6.3 8 | ports: 9 | - 9000:9000 10 | environment: 11 | # The docker environment must be 12 | # set to 0.0.0.0 to allow external access 13 | HOST: "0.0.0.0" 14 | PORT: 9000 15 | # Hot reload mode can be enabled to 16 | # start the service when there is no configuration 17 | VERBOSE: true 18 | # set the hook configuration file to use 19 | HOOKS: "/app/hook-1.yaml,/app/hook-2.yaml" 20 | # Other configurable parameters... 21 | DEBUG: true 22 | # NO_PANIC: false 23 | # LOG_PATH: "" 24 | # HOT_RELOAD: false 25 | # URL_PREFIX: "" 26 | # TEMPLATE: false 27 | # HTTP_METHODS: "" 28 | # X_REQUEST_ID: false 29 | # set i18N (WIP) 30 | # LANGUAGE: zh-CN 31 | volumes: 32 | - ./hooks/hook-1.yaml:/app/hook-1.yaml 33 | - ./hooks/hook-2.yaml:/app/hook-2.yaml 34 | - ./trigger.sh:/app/trigger.sh 35 | -------------------------------------------------------------------------------- /example/muti-webhook/hooks/hook-1.yaml: -------------------------------------------------------------------------------- 1 | - id: hook-1 2 | execute-command: ./trigger.sh 3 | command-working-directory: /app 4 | include-command-output-in-response: true 5 | include-command-out-in-response-on-error: true 6 | pass-environment-to-command: 7 | - source: url 8 | name: text 9 | envname: TEXT 10 | -------------------------------------------------------------------------------- /example/muti-webhook/hooks/hook-2.yaml: -------------------------------------------------------------------------------- 1 | - id: hook-2 2 | execute-command: ./trigger.sh 3 | command-working-directory: /app 4 | include-command-output-in-response: true 5 | include-command-out-in-response-on-error: true 6 | pass-environment-to-command: 7 | - source: url 8 | name: text 9 | envname: TEXT 10 | -------------------------------------------------------------------------------- /example/muti-webhook/trigger.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -n "$TEXT" ]; then 4 | # whatever you want to do with $TEXT 5 | echo "ok, $TEXT" 6 | else 7 | echo "TEXT is empty" 8 | fi 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/soulteary/webhook 2 | 3 | go 1.23.3 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.4.0 7 | github.com/clbanning/mxj/v2 v2.7.0 8 | github.com/dustin/go-humanize v1.0.1 9 | github.com/fsnotify/fsnotify v1.8.0 10 | github.com/go-chi/chi/v5 v5.2.1 11 | github.com/google/uuid v1.6.0 12 | github.com/gorilla/mux v1.8.1 13 | github.com/invopop/yaml v0.3.1 14 | github.com/nicksnyder/go-i18n/v2 v2.5.1 15 | github.com/stretchr/testify v1.10.0 16 | golang.org/x/sys v0.31.0 17 | golang.org/x/text v0.23.0 18 | ) 19 | 20 | require ( 21 | github.com/davecgh/go-spew v1.1.1 // indirect 22 | github.com/google/go-cmp v0.6.0 // indirect 23 | github.com/kr/pretty v0.3.1 // indirect 24 | github.com/pmezard/go-difflib v1.0.0 // indirect 25 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 26 | gopkg.in/yaml.v3 v3.0.1 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= 2 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= 4 | github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= 5 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 9 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 10 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 11 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 12 | github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= 13 | github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 14 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 15 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 16 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 17 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 18 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 19 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 20 | github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= 21 | github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= 22 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 23 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 24 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 25 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 26 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 27 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 28 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 29 | github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= 30 | github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= 31 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 32 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 33 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 34 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 35 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 36 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 37 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 38 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 39 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 40 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 41 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 42 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 43 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 44 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 45 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 46 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 47 | -------------------------------------------------------------------------------- /internal/flags/cli.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import ( 4 | "flag" 5 | 6 | "github.com/soulteary/webhook/internal/hook" 7 | "github.com/soulteary/webhook/internal/rules" 8 | ) 9 | 10 | func ParseCLI(flags AppFlags) AppFlags { 11 | var ( 12 | Host = flag.String("ip", DEFAULT_HOST, "ip the webhook should serve hooks on") 13 | Port = flag.Int("port", DEFAULT_PORT, "port the webhook should serve hooks on") 14 | Verbose = flag.Bool("verbose", DEFAULT_ENABLE_VERBOSE, "show verbose output") 15 | LogPath = flag.String("logfile", DEFAULT_LOG_PATH, "send log output to a file; implicitly enables verbose logging") 16 | Debug = flag.Bool("debug", DEFAULT_ENABLE_DEBUG, "show debug output") 17 | NoPanic = flag.Bool("nopanic", DEFAULT_ENABLE_NO_PANIC, "do not panic if hooks cannot be loaded when webhook is not running in verbose mode") 18 | HotReload = flag.Bool("hotreload", DEFAULT_ENABLE_HOT_RELOAD, "watch hooks file for changes and reload them automatically") 19 | HooksURLPrefix = flag.String("urlprefix", DEFAULT_URL_PREFIX, "url prefix to use for served hooks (protocol://yourserver:port/PREFIX/:hook-id)") 20 | AsTemplate = flag.Bool("template", DEFAULT_ENABLE_PARSE_TEMPLATE, "parse hooks file as a Go template") 21 | UseXRequestID = flag.Bool("x-request-id", DEFAULT_ENABLE_X_REQUEST_ID, "use X-Request-Id header, if present, as request ID") 22 | XRequestIDLimit = flag.Int("x-request-id-limit", DEFAULT_X_REQUEST_ID_LIMIT, "truncate X-Request-Id header to limit; default no limit") 23 | MaxMultipartMem = flag.Int64("max-multipart-mem", DEFAULT_MAX_MPART_MEM, "maximum memory in bytes for parsing multipart form data before disk caching") 24 | SetGID = flag.Int("setgid", DEFAULT_GID, "set group ID after opening listening port; must be used with setuid") 25 | SetUID = flag.Int("setuid", DEFAULT_UID, "set user ID after opening listening port; must be used with setgid") 26 | HttpMethods = flag.String("http-methods", DEFAULT_HTTP_METHODS, `set default allowed HTTP methods (ie. "POST"); separate methods with comma`) 27 | PidPath = flag.String("pidfile", DEFAULT_PID_FILE, "create PID file at the given path") 28 | 29 | Lang = flag.String("lang", DEFAULT_LANG, "set the language code for the webhook") 30 | I18nDir = flag.String("lang-dir", DEFAULT_I18N_DIR, "set the directory for the i18n files") 31 | 32 | ShowVersion = flag.Bool("version", false, "display webhook version and quit") 33 | ResponseHeaders hook.ResponseHeaders 34 | ) 35 | 36 | hooksFiles := rules.HooksFiles 37 | flag.Var(&hooksFiles, "hooks", "path to the json file containing defined hooks the webhook should serve, use multiple times to load from different files") 38 | flag.Var(&ResponseHeaders, "header", "response header to return, specified in format name=value, use multiple times to set multiple headers") 39 | 40 | flag.Parse() 41 | 42 | if *Host != DEFAULT_HOST { 43 | flags.Host = *Host 44 | } 45 | 46 | if *Port != DEFAULT_PORT { 47 | flags.Port = *Port 48 | } 49 | 50 | if *Verbose != DEFAULT_ENABLE_VERBOSE { 51 | flags.Verbose = *Verbose 52 | } 53 | 54 | if *LogPath != DEFAULT_LOG_PATH { 55 | flags.LogPath = *LogPath 56 | } 57 | 58 | if *Debug != DEFAULT_ENABLE_DEBUG { 59 | flags.Debug = *Debug 60 | } 61 | 62 | if *NoPanic != DEFAULT_ENABLE_NO_PANIC { 63 | flags.NoPanic = *NoPanic 64 | } 65 | 66 | if *HotReload != DEFAULT_ENABLE_HOT_RELOAD { 67 | flags.HotReload = *HotReload 68 | } 69 | 70 | if *HooksURLPrefix != DEFAULT_URL_PREFIX { 71 | flags.HooksURLPrefix = *HooksURLPrefix 72 | } 73 | 74 | if *AsTemplate != DEFAULT_ENABLE_PARSE_TEMPLATE { 75 | flags.AsTemplate = *AsTemplate 76 | } 77 | 78 | if *UseXRequestID != DEFAULT_ENABLE_X_REQUEST_ID { 79 | flags.UseXRequestID = *UseXRequestID 80 | } 81 | 82 | if *XRequestIDLimit != DEFAULT_X_REQUEST_ID_LIMIT { 83 | flags.XRequestIDLimit = *XRequestIDLimit 84 | } 85 | 86 | if *MaxMultipartMem != DEFAULT_MAX_MPART_MEM { 87 | flags.MaxMultipartMem = *MaxMultipartMem 88 | } 89 | 90 | if *SetGID != DEFAULT_GID { 91 | flags.SetGID = *SetGID 92 | } 93 | 94 | if *SetUID != DEFAULT_UID { 95 | flags.SetUID = *SetUID 96 | } 97 | 98 | if *HttpMethods != DEFAULT_HTTP_METHODS { 99 | flags.HttpMethods = *HttpMethods 100 | } 101 | 102 | if *PidPath != DEFAULT_PID_FILE { 103 | flags.PidPath = *PidPath 104 | } 105 | 106 | if *ShowVersion { 107 | flags.ShowVersion = true 108 | } 109 | 110 | if len(hooksFiles) > 0 { 111 | flags.HooksFiles = append(flags.HooksFiles, hooksFiles...) 112 | } 113 | rules.HooksFiles = flags.HooksFiles 114 | 115 | if len(ResponseHeaders) > 0 { 116 | flags.ResponseHeaders = ResponseHeaders 117 | } 118 | 119 | if *Lang != DEFAULT_LANG { 120 | flags.Lang = *Lang 121 | } 122 | 123 | if *I18nDir != DEFAULT_I18N_DIR { 124 | flags.I18nDir = *I18nDir 125 | } 126 | return flags 127 | } 128 | -------------------------------------------------------------------------------- /internal/flags/define.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import "github.com/soulteary/webhook/internal/hook" 4 | 5 | const ( 6 | DEFAULT_HOST = "0.0.0.0" 7 | DEFAULT_PORT = 9000 8 | 9 | DEFAULT_LOG_PATH = "" 10 | DEFAULT_URL_PREFIX = "hooks" 11 | DEFAULT_HTTP_METHODS = "" 12 | DEFAULT_PID_FILE = "" 13 | 14 | DEFAULT_ENABLE_VERBOSE = false 15 | DEFAULT_ENABLE_DEBUG = false 16 | DEFAULT_ENABLE_NO_PANIC = false 17 | DEFAULT_ENABLE_HOT_RELOAD = false 18 | DEFAULT_ENABLE_PARSE_TEMPLATE = false 19 | DEFAULT_ENABLE_X_REQUEST_ID = false 20 | 21 | DEFAULT_X_REQUEST_ID_LIMIT = 0 22 | DEFAULT_MAX_MPART_MEM = 1 << 20 23 | DEFAULT_GID = 0 24 | DEFAULT_UID = 0 25 | 26 | DEFAULT_LANG = "en-US" 27 | DEFAULT_I18N_DIR = "./locales" 28 | ) 29 | 30 | const ( 31 | ENV_KEY_HOST = "HOST" 32 | ENV_KEY_PORT = "PORT" 33 | 34 | ENV_KEY_VERBOSE = "VERBOSE" 35 | ENV_KEY_DEBUG = "DEBUG" 36 | ENV_KEY_NO_PANIC = "NO_PANIC" 37 | ENV_KEY_LOG_PATH = "LOG_PATH" 38 | ENV_KEY_HOT_RELOAD = "HOT_RELOAD" 39 | 40 | ENV_KEY_HOOKS_URLPREFIX = "URL_PREFIX" 41 | ENV_KEY_HOOKS = "HOOKS" 42 | ENV_KEY_TEMPLATE = "TEMPLATE" 43 | ENV_KEY_HTTP_METHODS = "HTTP_METHODS" 44 | ENV_KEY_PID_FILE = "PID_FILE" 45 | ENV_KEY_X_REQUEST_ID = "X_REQUEST_ID" 46 | ENV_KEY_MAX_MPART_MEM = "MAX_MPART_MEM" 47 | ENV_KEY_GID = "GID" 48 | ENV_KEY_UID = "UID" 49 | ENV_KEY_HEADER = "HEADER" 50 | 51 | ENV_KEY_LANG = "LANGUAGE" 52 | ENV_KEY_I18N = "LANG_DIR" 53 | ) 54 | 55 | type AppFlags struct { 56 | Host string 57 | Port int 58 | Verbose bool 59 | LogPath string 60 | Debug bool 61 | NoPanic bool 62 | HotReload bool 63 | HooksURLPrefix string 64 | AsTemplate bool 65 | UseXRequestID bool 66 | XRequestIDLimit int 67 | MaxMultipartMem int64 68 | SetGID int 69 | SetUID int 70 | HttpMethods string 71 | PidPath string 72 | 73 | ShowVersion bool 74 | HooksFiles hook.HooksFiles 75 | ResponseHeaders hook.ResponseHeaders 76 | 77 | Lang string 78 | I18nDir string 79 | } 80 | -------------------------------------------------------------------------------- /internal/flags/envs.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/soulteary/webhook/internal/fn" 8 | "github.com/soulteary/webhook/internal/hook" 9 | ) 10 | 11 | func ParseEnvs() AppFlags { 12 | var flags AppFlags 13 | flags.Host = fn.GetEnvStr(ENV_KEY_HOST, DEFAULT_HOST) 14 | flags.Port = fn.GetEnvInt(ENV_KEY_PORT, DEFAULT_PORT) 15 | flags.Verbose = fn.GetEnvBool(ENV_KEY_VERBOSE, DEFAULT_ENABLE_VERBOSE) 16 | flags.LogPath = fn.GetEnvStr(ENV_KEY_LOG_PATH, DEFAULT_LOG_PATH) 17 | flags.Debug = fn.GetEnvBool(ENV_KEY_DEBUG, DEFAULT_ENABLE_DEBUG) 18 | flags.NoPanic = fn.GetEnvBool(ENV_KEY_NO_PANIC, DEFAULT_ENABLE_NO_PANIC) 19 | flags.HotReload = fn.GetEnvBool(ENV_KEY_HOT_RELOAD, DEFAULT_ENABLE_HOT_RELOAD) 20 | flags.HooksURLPrefix = fn.GetEnvStr(ENV_KEY_HOOKS_URLPREFIX, DEFAULT_URL_PREFIX) 21 | flags.AsTemplate = fn.GetEnvBool(ENV_KEY_TEMPLATE, DEFAULT_ENABLE_PARSE_TEMPLATE) 22 | flags.UseXRequestID = fn.GetEnvBool(ENV_KEY_X_REQUEST_ID, DEFAULT_ENABLE_X_REQUEST_ID) 23 | flags.XRequestIDLimit = fn.GetEnvInt(ENV_KEY_X_REQUEST_ID, DEFAULT_X_REQUEST_ID_LIMIT) 24 | flags.MaxMultipartMem = int64(fn.GetEnvInt(ENV_KEY_MAX_MPART_MEM, DEFAULT_MAX_MPART_MEM)) 25 | flags.SetGID = fn.GetEnvInt(ENV_KEY_GID, DEFAULT_GID) 26 | flags.SetUID = fn.GetEnvInt(ENV_KEY_UID, DEFAULT_UID) 27 | flags.HttpMethods = fn.GetEnvStr(ENV_KEY_HTTP_METHODS, DEFAULT_HTTP_METHODS) 28 | flags.PidPath = fn.GetEnvStr(ENV_KEY_PID_FILE, DEFAULT_PID_FILE) 29 | 30 | // init i18n, set lang and i18n dir 31 | flags.Lang = fn.GetEnvStr(ENV_KEY_LANG, DEFAULT_LANG) 32 | flags.I18nDir = fn.GetEnvStr(ENV_KEY_I18N, DEFAULT_I18N_DIR) 33 | 34 | hooks := strings.Split(fn.GetEnvStr(ENV_KEY_HOOKS, ""), ",") 35 | var hooksFiles hook.HooksFiles 36 | for _, hook := range hooks { 37 | err := hooksFiles.Set(hook) 38 | if err != nil { 39 | fmt.Println("Error parsing hooks from environment variable: ", err) 40 | } 41 | } 42 | if len(hooksFiles) > 0 { 43 | flags.HooksFiles = hooksFiles 44 | } 45 | return flags 46 | } 47 | -------------------------------------------------------------------------------- /internal/flags/flags.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | func Parse() AppFlags { 4 | envs := ParseEnvs() 5 | cli := ParseCLI(envs) 6 | return cli 7 | } 8 | -------------------------------------------------------------------------------- /internal/fn/env.go: -------------------------------------------------------------------------------- 1 | package fn 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | func GetEnvStr(key, defaultValue string) string { 10 | v, ok := os.LookupEnv(key) 11 | if !ok { 12 | return defaultValue 13 | } 14 | value := strings.TrimSpace(v) 15 | if value == "" { 16 | return "" 17 | } 18 | return value 19 | } 20 | 21 | func GetEnvBool(key string, defaultValue bool) bool { 22 | value := strings.TrimSpace(os.Getenv(key)) 23 | if value == "" { 24 | return defaultValue 25 | } 26 | if value == "true" || value == "1" || value == "on" || value == "yes" { 27 | return true 28 | } 29 | return false 30 | } 31 | 32 | func GetEnvInt(key string, defaultValue int) int { 33 | value := strings.TrimSpace(os.Getenv(key)) 34 | if value == "" { 35 | return defaultValue 36 | } 37 | 38 | i, err := strconv.Atoi(value) 39 | if err != nil { 40 | return defaultValue 41 | } 42 | return i 43 | } 44 | -------------------------------------------------------------------------------- /internal/fn/env_test.go: -------------------------------------------------------------------------------- 1 | package fn_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/soulteary/webhook/internal/fn" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestGetEnvStr(t *testing.T) { 12 | // Set a test environment variable 13 | os.Setenv("TEST_ENV_STR", " test value ") 14 | defer os.Unsetenv("TEST_ENV_STR") 15 | 16 | // Test the GetEnvStr function 17 | assert.Equal(t, "test value", fn.GetEnvStr("TEST_ENV_STR", "default")) 18 | assert.Equal(t, "default", fn.GetEnvStr("MISSING_ENV_VAR", "default")) 19 | } 20 | 21 | func TestGetEnvBool(t *testing.T) { 22 | // Set test environment variables 23 | os.Setenv("TEST_ENV_BOOL_TRUE", "true") 24 | os.Setenv("TEST_ENV_BOOL_FALSE", "false") 25 | os.Setenv("TEST_ENV_BOOL_1", "1") 26 | os.Setenv("TEST_ENV_BOOL_0", "0") 27 | os.Setenv("TEST_ENV_BOOL_ON", "on") 28 | os.Setenv("TEST_ENV_BOOL_OFF", "off") 29 | os.Setenv("TEST_ENV_BOOL_YES", "yes") 30 | os.Setenv("TEST_ENV_BOOL_NO", "no") 31 | os.Setenv("TEST_ENV_BOOL_EMPTY", "") 32 | defer func() { 33 | os.Unsetenv("TEST_ENV_BOOL_TRUE") 34 | os.Unsetenv("TEST_ENV_BOOL_FALSE") 35 | os.Unsetenv("TEST_ENV_BOOL_1") 36 | os.Unsetenv("TEST_ENV_BOOL_0") 37 | os.Unsetenv("TEST_ENV_BOOL_ON") 38 | os.Unsetenv("TEST_ENV_BOOL_OFF") 39 | os.Unsetenv("TEST_ENV_BOOL_YES") 40 | os.Unsetenv("TEST_ENV_BOOL_NO") 41 | os.Unsetenv("TEST_ENV_BOOL_EMPTY") 42 | }() 43 | 44 | // Test the GetEnvBool function 45 | assert.True(t, fn.GetEnvBool("TEST_ENV_BOOL_TRUE", false)) 46 | assert.False(t, fn.GetEnvBool("TEST_ENV_BOOL_FALSE", true)) 47 | assert.True(t, fn.GetEnvBool("TEST_ENV_BOOL_1", false)) 48 | assert.False(t, fn.GetEnvBool("TEST_ENV_BOOL_0", true)) 49 | assert.True(t, fn.GetEnvBool("TEST_ENV_BOOL_ON", false)) 50 | assert.False(t, fn.GetEnvBool("TEST_ENV_BOOL_OFF", true)) 51 | assert.True(t, fn.GetEnvBool("TEST_ENV_BOOL_YES", false)) 52 | assert.False(t, fn.GetEnvBool("TEST_ENV_BOOL_NO", true)) 53 | assert.False(t, fn.GetEnvBool("TEST_ENV_BOOL_EMPTY", false)) 54 | assert.True(t, fn.GetEnvBool("MISSING_ENV_VAR", true)) 55 | } 56 | 57 | func TestGetEnvInt(t *testing.T) { 58 | // Set test environment variables 59 | os.Setenv("TEST_ENV_INT_VALID", "42") 60 | os.Setenv("TEST_ENV_INT_INVALID", "invalid") 61 | os.Setenv("TEST_ENV_INT_EMPTY", "") 62 | defer func() { 63 | os.Unsetenv("TEST_ENV_INT_VALID") 64 | os.Unsetenv("TEST_ENV_INT_INVALID") 65 | os.Unsetenv("TEST_ENV_INT_EMPTY") 66 | }() 67 | 68 | // Test the GetEnvInt function 69 | assert.Equal(t, 42, fn.GetEnvInt("TEST_ENV_INT_VALID", 0)) 70 | assert.Equal(t, 0, fn.GetEnvInt("TEST_ENV_INT_INVALID", 0)) 71 | assert.Equal(t, 0, fn.GetEnvInt("TEST_ENV_INT_EMPTY", 0)) 72 | assert.Equal(t, 10, fn.GetEnvInt("MISSING_ENV_VAR", 10)) 73 | } 74 | -------------------------------------------------------------------------------- /internal/fn/i18n.go: -------------------------------------------------------------------------------- 1 | package fn 2 | 3 | import "golang.org/x/text/language" 4 | 5 | // get verified local code 6 | func GetVerifiedLocalCode(targetCode string) string { 7 | var tag language.Tag 8 | err := tag.UnmarshalText([]byte(targetCode)) 9 | if err != nil { 10 | return "" 11 | } 12 | b, err := tag.MarshalText() 13 | if err != nil { 14 | return "" 15 | } 16 | 17 | verified := string(b) 18 | if verified != targetCode { 19 | return "" 20 | } 21 | return verified 22 | } 23 | -------------------------------------------------------------------------------- /internal/fn/i18n_test.go: -------------------------------------------------------------------------------- 1 | package fn_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/soulteary/webhook/internal/fn" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGetVerifiedLocalCode(t *testing.T) { 11 | // Test cases 12 | testCases := []struct { 13 | name string 14 | input string 15 | expectedCode string 16 | }{ 17 | { 18 | name: "Valid input", 19 | input: "en-US", 20 | expectedCode: "en-US", 21 | }, 22 | { 23 | name: "Invalid input", 24 | input: "invalid-code", 25 | expectedCode: "", 26 | }, 27 | { 28 | name: "Empty input", 29 | input: "", 30 | expectedCode: "", 31 | }, 32 | } 33 | 34 | for _, tc := range testCases { 35 | t.Run(tc.name, func(t *testing.T) { 36 | result := fn.GetVerifiedLocalCode(tc.input) 37 | assert.Equal(t, tc.expectedCode, result) 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/fn/io.go: -------------------------------------------------------------------------------- 1 | package fn 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | func ScanDirByExt(filePath string, fileExt string) []string { 10 | _, err := os.Stat(filePath) 11 | if err != nil { 12 | return nil 13 | } 14 | 15 | var result []string 16 | ext := "." + strings.ReplaceAll(strings.ToLower(fileExt), ".", "") 17 | err = filepath.Walk(filePath, func(path string, info os.FileInfo, err error) error { 18 | if err != nil { 19 | return err 20 | } 21 | if !info.IsDir() && filepath.Ext(strings.ToLower(path)) == ext { 22 | result = append(result, path) 23 | } 24 | return nil 25 | }) 26 | if err != nil { 27 | panic(err) 28 | } 29 | return result 30 | } 31 | -------------------------------------------------------------------------------- /internal/fn/io_test.go: -------------------------------------------------------------------------------- 1 | package fn_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/soulteary/webhook/internal/fn" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestScanDirByExt(t *testing.T) { 13 | tempDir := t.TempDir() 14 | testFile1 := filepath.Join(tempDir, "test1.txt") 15 | testFile2 := filepath.Join(tempDir, "test2.jpg") 16 | testFile3 := filepath.Join(tempDir, "test3.txt") 17 | 18 | os.Create(testFile1) 19 | os.Create(testFile2) 20 | os.Create(testFile3) 21 | 22 | defer os.Remove(testFile1) 23 | defer os.Remove(testFile2) 24 | defer os.Remove(testFile3) 25 | 26 | txtFiles := fn.ScanDirByExt(tempDir, ".txt") 27 | assert.Equal(t, []string{testFile1, testFile3}, txtFiles) 28 | 29 | jpgFiles := fn.ScanDirByExt(tempDir, ".jpg") 30 | assert.Equal(t, []string{testFile2}, jpgFiles) 31 | 32 | nonExistentPath := filepath.Join(tempDir, "non-existent") 33 | nonExistentFiles := fn.ScanDirByExt(nonExistentPath, ".txt") 34 | assert.Nil(t, nonExistentFiles) 35 | } 36 | -------------------------------------------------------------------------------- /internal/fn/server.go: -------------------------------------------------------------------------------- 1 | package fn 2 | 3 | import "strings" 4 | 5 | func RemoveNewlinesAndTabs(src string) string { 6 | return strings.ReplaceAll(strings.ReplaceAll(src, "\n", ""), "\r", "") 7 | } 8 | -------------------------------------------------------------------------------- /internal/hook/hook_new_test.go: -------------------------------------------------------------------------------- 1 | package hook_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/soulteary/webhook/internal/hook" 8 | ) 9 | 10 | func TestParameterNodeError_Error(t *testing.T) { 11 | // Test 1: Error message when e is not nil 12 | err := &hook.ParameterNodeError{Key: "missing_key"} 13 | expectedMessage := "parameter node not found: missing_key" 14 | if err.Error() != expectedMessage { 15 | t.Errorf("Expected message %q, got %q", expectedMessage, err.Error()) 16 | } 17 | 18 | // Test 2: Error message when e is nil 19 | var nilErr *hook.ParameterNodeError 20 | expectedNilMessage := "" 21 | if nilErr.Error() != expectedNilMessage { 22 | t.Errorf("Expected message %q for nil error, got %q", expectedNilMessage, nilErr.Error()) 23 | } 24 | } 25 | 26 | func TestIsParameterNodeError(t *testing.T) { 27 | // Test with ParameterNodeError type 28 | e := &hook.ParameterNodeError{} 29 | if !hook.IsParameterNodeError(e) { 30 | t.Error("Expected true, got false") 31 | } 32 | 33 | // Test with different error type 34 | notE := fmt.Errorf("some other error") 35 | if hook.IsParameterNodeError(notE) { 36 | t.Error("Expected false, got true") 37 | } 38 | 39 | // Test with nil 40 | if hook.IsParameterNodeError(nil) { 41 | t.Error("Expected false, got true") 42 | } 43 | } 44 | 45 | func TestSignatureError_Error(t *testing.T) { 46 | tests := []struct { 47 | sigError *hook.SignatureError 48 | expectedErrorMsg string 49 | }{ 50 | { 51 | sigError: &hook.SignatureError{Signature: "signature1", EmptyPayload: false}, 52 | expectedErrorMsg: "invalid payload signature signature1", 53 | }, 54 | { 55 | sigError: &hook.SignatureError{Signature: "signature2", EmptyPayload: true}, 56 | expectedErrorMsg: "invalid payload signature signature2 on empty payload", 57 | }, 58 | { 59 | sigError: &hook.SignatureError{Signatures: []string{"sig1", "sig2"}, EmptyPayload: false}, 60 | expectedErrorMsg: "invalid payload signatures [sig1 sig2]", 61 | }, 62 | { 63 | sigError: &hook.SignatureError{Signatures: []string{"sig3", "sig4"}, EmptyPayload: true}, 64 | expectedErrorMsg: "invalid payload signatures [sig3 sig4] on empty payload", 65 | }, 66 | { 67 | sigError: nil, 68 | expectedErrorMsg: "", 69 | }, 70 | } 71 | 72 | for _, tt := range tests { 73 | errorMsg := tt.sigError.Error() 74 | if errorMsg != tt.expectedErrorMsg { 75 | t.Errorf("Expected error message %q, got %q", tt.expectedErrorMsg, errorMsg) 76 | } 77 | } 78 | } 79 | 80 | func TestIsSignatureError(t *testing.T) { 81 | // Test with SignatureError type 82 | e := &hook.SignatureError{} 83 | if !hook.IsSignatureError(e) { 84 | t.Error("Expected true, got false") 85 | } 86 | 87 | // Test with different error type 88 | notE := fmt.Errorf("some other error") 89 | if hook.IsSignatureError(notE) { 90 | t.Error("Expected false, got true") 91 | } 92 | 93 | // Test with nil 94 | if hook.IsSignatureError(nil) { 95 | t.Error("Expected false, got true") 96 | } 97 | } 98 | 99 | func TestArgumentError_Error(t *testing.T) { 100 | argErr := &hook.ArgumentError{Argument: hook.Argument{Name: "arg_name"}} 101 | expectedMessage := "couldn't retrieve argument for {Source: Name:arg_name EnvName: Base64Decode:false}" 102 | if argErr.Error() != expectedMessage { 103 | t.Errorf("Expected message %q, got %q", expectedMessage, argErr.Error()) 104 | } 105 | } 106 | 107 | func TestSourceError_Error(t *testing.T) { 108 | srcErr := &hook.SourceError{Argument: hook.Argument{Name: "src_name"}} 109 | expectedMessage := "invalid source for argument {Source: Name:src_name EnvName: Base64Decode:false}" 110 | if srcErr.Error() != expectedMessage { 111 | t.Errorf("Expected message %q, got %q", expectedMessage, srcErr.Error()) 112 | } 113 | } 114 | 115 | func TestParseError_Error(t *testing.T) { 116 | parseErr := &hook.ParseError{Err: fmt.Errorf("specific parse error")} 117 | expectedMessage := "specific parse error" 118 | if parseErr.Error() != expectedMessage { 119 | t.Errorf("Expected message %q, got %q", expectedMessage, parseErr.Error()) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /internal/hook/request.go: -------------------------------------------------------------------------------- 1 | package hook 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "unicode" 10 | 11 | "github.com/clbanning/mxj/v2" 12 | ) 13 | 14 | // Request represents a webhook request. 15 | type Request struct { 16 | // The request ID set by the RequestID middleware. 17 | ID string 18 | 19 | // The Content-Type of the request. 20 | ContentType string 21 | 22 | // The raw request body. 23 | Body []byte 24 | 25 | // Headers is a map of the parsed headers. 26 | Headers map[string]interface{} 27 | 28 | // Query is a map of the parsed URL query values. 29 | Query map[string]interface{} 30 | 31 | // Payload is a map of the parsed payload. 32 | Payload map[string]interface{} 33 | 34 | // The underlying HTTP request. 35 | RawRequest *http.Request 36 | 37 | // Treat signature errors as simple validate failures. 38 | AllowSignatureErrors bool 39 | } 40 | 41 | func (r *Request) ParseJSONPayload() error { 42 | decoder := json.NewDecoder(bytes.NewReader(r.Body)) 43 | decoder.UseNumber() 44 | 45 | var firstChar byte 46 | for i := 0; i < len(r.Body); i++ { 47 | if unicode.IsSpace(rune(r.Body[i])) { 48 | continue 49 | } 50 | firstChar = r.Body[i] 51 | break 52 | } 53 | 54 | if firstChar == byte('[') { 55 | var arrayPayload interface{} 56 | err := decoder.Decode(&arrayPayload) 57 | if err != nil { 58 | return fmt.Errorf("error parsing JSON array payload %+v", err) 59 | } 60 | 61 | r.Payload = make(map[string]interface{}, 1) 62 | r.Payload["root"] = arrayPayload 63 | } else { 64 | err := decoder.Decode(&r.Payload) 65 | if err != nil { 66 | return fmt.Errorf("error parsing JSON payload %+v", err) 67 | } 68 | } 69 | 70 | return nil 71 | } 72 | 73 | func (r *Request) ParseHeaders(headers map[string][]string) { 74 | r.Headers = make(map[string]interface{}, len(headers)) 75 | 76 | for k, v := range headers { 77 | if len(v) > 0 { 78 | r.Headers[k] = v[0] 79 | } 80 | } 81 | } 82 | 83 | func (r *Request) ParseQuery(query map[string][]string) { 84 | r.Query = make(map[string]interface{}, len(query)) 85 | 86 | for k, v := range query { 87 | if len(v) > 0 { 88 | r.Query[k] = v[0] 89 | } 90 | } 91 | } 92 | 93 | func (r *Request) ParseFormPayload() error { 94 | fd, err := url.ParseQuery(string(r.Body)) 95 | if err != nil { 96 | return fmt.Errorf("error parsing form payload %+v", err) 97 | } 98 | 99 | r.Payload = make(map[string]interface{}, len(fd)) 100 | 101 | for k, v := range fd { 102 | if len(v) > 0 { 103 | r.Payload[k] = v[0] 104 | } 105 | } 106 | 107 | return nil 108 | } 109 | 110 | func (r *Request) ParseXMLPayload() error { 111 | var err error 112 | 113 | r.Payload, err = mxj.NewMapXmlReader(bytes.NewReader(r.Body)) 114 | if err != nil { 115 | return fmt.Errorf("error parsing XML payload: %+v", err) 116 | } 117 | 118 | return nil 119 | } 120 | -------------------------------------------------------------------------------- /internal/hook/request_test.go: -------------------------------------------------------------------------------- 1 | package hook 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | var parseJSONPayloadTests = []struct { 10 | body []byte 11 | payload map[string]interface{} 12 | ok bool 13 | }{ 14 | {[]byte(`[1,2,3]`), map[string]interface{}{"root": []interface{}{json.Number("1"), json.Number("2"), json.Number("3")}}, true}, 15 | {[]byte(` [1,2,3]`), map[string]interface{}{"root": []interface{}{json.Number("1"), json.Number("2"), json.Number("3")}}, true}, 16 | {[]byte(`{"key": "value"}`), map[string]interface{}{"key": "value"}, true}, 17 | {[]byte(`[1, {"]`), map[string]interface{}(nil), false}, 18 | {[]byte(`{"key": "value}`), map[string]interface{}(nil), false}, 19 | } 20 | 21 | func TestParseJSONPayload(t *testing.T) { 22 | for _, tt := range parseJSONPayloadTests { 23 | r := Request{ 24 | Body: tt.body, 25 | } 26 | err := r.ParseJSONPayload() 27 | if (err == nil) != tt.ok { 28 | t.Errorf("unexpected result given %q: %s\n", string(tt.body), err) 29 | } 30 | 31 | if !reflect.DeepEqual(tt.payload, r.Payload) { 32 | t.Errorf("failed to parse json %q:\nexpected %#v,\ngot %#v", string(tt.body), tt.payload, r.Payload) 33 | } 34 | } 35 | } 36 | 37 | var parseHeadersTests = []struct { 38 | headers map[string][]string 39 | expectedHeaders map[string]interface{} 40 | }{ 41 | { 42 | map[string][]string{"header1": {"12"}}, 43 | map[string]interface{}{"header1": "12"}, 44 | }, 45 | { 46 | map[string][]string{"header1": {"12", "34"}}, 47 | map[string]interface{}{"header1": "12"}, 48 | }, 49 | { 50 | map[string][]string{"header1": {}}, 51 | map[string]interface{}{}, 52 | }, 53 | } 54 | 55 | func TestParseHeaders(t *testing.T) { 56 | for _, tt := range parseHeadersTests { 57 | r := Request{} 58 | r.ParseHeaders(tt.headers) 59 | 60 | if !reflect.DeepEqual(tt.expectedHeaders, r.Headers) { 61 | t.Errorf("failed to parse headers %#v:\nexpected %#v,\ngot %#v", tt.headers, tt.expectedHeaders, r.Headers) 62 | } 63 | } 64 | } 65 | 66 | var parseQueryTests = []struct { 67 | query map[string][]string 68 | expectedQuery map[string]interface{} 69 | }{ 70 | { 71 | map[string][]string{"query1": {"12"}}, 72 | map[string]interface{}{"query1": "12"}, 73 | }, 74 | { 75 | map[string][]string{"query1": {"12", "34"}}, 76 | map[string]interface{}{"query1": "12"}, 77 | }, 78 | { 79 | map[string][]string{"query1": {}}, 80 | map[string]interface{}{}, 81 | }, 82 | } 83 | 84 | func TestParseQuery(t *testing.T) { 85 | for _, tt := range parseQueryTests { 86 | r := Request{} 87 | r.ParseQuery(tt.query) 88 | 89 | if !reflect.DeepEqual(tt.expectedQuery, r.Query) { 90 | t.Errorf("failed to parse query %#v:\nexpected %#v,\ngot %#v", tt.query, tt.expectedQuery, r.Query) 91 | } 92 | } 93 | } 94 | 95 | var parseFormPayloadTests = []struct { 96 | body []byte 97 | expectedPayload map[string]interface{} 98 | ok bool 99 | }{ 100 | { 101 | []byte("x=1&y=2"), 102 | map[string]interface{}{"x": "1", "y": "2"}, 103 | true, 104 | }, 105 | { 106 | []byte("x=1&y=2&y=3"), 107 | map[string]interface{}{"x": "1", "y": "2"}, 108 | true, 109 | }, 110 | { 111 | []byte(";"), 112 | map[string]interface{}(nil), 113 | false, 114 | }, 115 | } 116 | 117 | func TestParseFormPayload(t *testing.T) { 118 | for _, tt := range parseFormPayloadTests { 119 | r := Request{ 120 | Body: tt.body, 121 | } 122 | err := r.ParseFormPayload() 123 | if (err == nil) != tt.ok { 124 | t.Errorf("unexpected result given %q: %s\n", string(tt.body), err) 125 | } 126 | 127 | if !reflect.DeepEqual(tt.expectedPayload, r.Payload) { 128 | t.Errorf("failed to parse form payload %q:\nexpected %#v,\ngot %#v", string(tt.body), tt.expectedPayload, r.Payload) 129 | } 130 | } 131 | } 132 | 133 | var parseXMLPayloadTests = []struct { 134 | body []byte 135 | expectedPayload map[string]interface{} 136 | ok bool 137 | }{ 138 | { 139 | []byte("1"), 140 | map[string]interface{}{"x": "1"}, 141 | true, 142 | }, 143 | { 144 | []byte("1"), 145 | map[string]interface{}(nil), 146 | false, 147 | }, 148 | } 149 | 150 | func TestParseXMLPayload(t *testing.T) { 151 | for _, tt := range parseXMLPayloadTests { 152 | r := Request{ 153 | Body: tt.body, 154 | } 155 | err := r.ParseXMLPayload() 156 | if (err == nil) != tt.ok { 157 | t.Errorf("unexpected result given %q: %s\n", string(tt.body), err) 158 | } 159 | 160 | if !reflect.DeepEqual(tt.expectedPayload, r.Payload) { 161 | t.Errorf("failed to parse xml %q:\nexpected %#v,\ngot %#v", string(tt.body), tt.expectedPayload, r.Payload) 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /internal/hook/testdata/unrecognized.yaml: -------------------------------------------------------------------------------- 1 | - id: webhook 2 | unrecognized-execute-command: /home/adnan/redeploy-go-webhook.sh 3 | command-working-directory: /home/adnan/go 4 | response-message: I got the payload! 5 | response-headers: 6 | - name: Access-Control-Allow-Origin 7 | value: '*' 8 | pass-arguments-to-command: 9 | - source: payload 10 | name: head_commit.id 11 | - source: payload 12 | name: pusher.name 13 | - source: payload 14 | name: pusher.email 15 | trigger-rule: 16 | and: 17 | - match: 18 | type: payload-hmac-sha1 19 | secret: mysecret 20 | parameter: 21 | source: header 22 | name: X-Hub-Signature 23 | - match: 24 | type: value 25 | value: refs/heads/master 26 | parameter: 27 | source: payload 28 | name: ref 29 | -------------------------------------------------------------------------------- /internal/i18n/i18n.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/BurntSushi/toml" 12 | "github.com/nicksnyder/go-i18n/v2/i18n" 13 | "github.com/soulteary/webhook/internal/fn" 14 | "golang.org/x/text/language" 15 | ) 16 | 17 | type WebHookLocales struct { 18 | FileName string 19 | Name string 20 | Content []byte 21 | } 22 | 23 | // get alive locales 24 | func LoadLocaleFiles(localesDir string, webhookLocalesEmbed embed.FS) (aliveLocales []WebHookLocales) { 25 | localesFiles := fn.ScanDirByExt(localesDir, ".toml") 26 | // when no locales files found, use the embed locales files 27 | if len(localesFiles) == 0 { 28 | files, err := webhookLocalesEmbed.ReadDir("locales") 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | for _, file := range files { 33 | fileName := file.Name() 34 | data, err := webhookLocalesEmbed.ReadFile("locales/" + fileName) 35 | if err != nil { 36 | fmt.Println(file, err) 37 | continue 38 | } 39 | locales, err := GetWebHookLocaleObject(fileName, data) 40 | if err != nil { 41 | fmt.Println(file, err) 42 | continue 43 | } 44 | aliveLocales = append(aliveLocales, locales) 45 | } 46 | return aliveLocales 47 | } 48 | 49 | for _, file := range localesFiles { 50 | content, err := os.ReadFile(filepath.Clean(file)) 51 | if err != nil { 52 | fmt.Println(file, err) 53 | continue 54 | } 55 | 56 | locales, err := GetWebHookLocaleObject(file, content) 57 | if err != nil { 58 | fmt.Println(file, err) 59 | continue 60 | } 61 | aliveLocales = append(aliveLocales, locales) 62 | } 63 | return aliveLocales 64 | } 65 | 66 | func GetWebHookLocaleObject(fileName string, content []byte) (result WebHookLocales, err error) { 67 | localeNameFromFile := strings.Replace(filepath.Base(fileName), ".toml", "", -1) 68 | verified := fn.GetVerifiedLocalCode(localeNameFromFile) 69 | 70 | if verified == "" { 71 | return result, fmt.Errorf("invalid locale name") 72 | } 73 | 74 | return WebHookLocales{ 75 | FileName: fileName, 76 | Name: localeNameFromFile, 77 | Content: content, 78 | }, nil 79 | } 80 | 81 | type WebHookLocalizer struct { 82 | FileName string 83 | Name string 84 | Bundle *i18n.Bundle 85 | Localizer *i18n.Localizer 86 | } 87 | 88 | var ( 89 | GLOBAL_LOCALES map[string]WebHookLocalizer 90 | GLOBAL_LANG string 91 | ) 92 | 93 | func SetGlobalLocale(lang string) { 94 | GLOBAL_LANG = lang 95 | } 96 | 97 | func InitLocaleByFiles(aliveLocales []WebHookLocales) (bundleMaps map[string]WebHookLocalizer) { 98 | bundleMaps = make(map[string]WebHookLocalizer) 99 | for _, locale := range aliveLocales { 100 | bundle := i18n.NewBundle(language.MustParse(locale.Name)) 101 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 102 | bundle.MustParseMessageFileBytes(locale.Content, locale.FileName) 103 | bundleMaps[locale.Name] = WebHookLocalizer{ 104 | FileName: locale.FileName, 105 | Name: locale.Name, 106 | Bundle: bundle, 107 | Localizer: i18n.NewLocalizer(bundle, locale.Name), 108 | } 109 | } 110 | return bundleMaps 111 | } 112 | 113 | func GetMessage(messageID string) string { 114 | locale := GLOBAL_LANG 115 | localizer, ok := GLOBAL_LOCALES[locale] 116 | if !ok { 117 | return fmt.Sprintf("locale %s not found", locale) 118 | } 119 | return localizer.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: messageID}) 120 | } 121 | 122 | func Println(messageID string, a ...any) { 123 | if len(a) == 0 { 124 | fmt.Println(GetMessage(messageID)) 125 | } else { 126 | fmt.Println(GetMessage(messageID), a) 127 | } 128 | } 129 | 130 | func Sprintf(messageID string, a ...any) string { 131 | return fmt.Sprintf(GetMessage(messageID), a) 132 | } 133 | -------------------------------------------------------------------------------- /internal/i18n/i18n_test.go: -------------------------------------------------------------------------------- 1 | package i18n_test 2 | 3 | import ( 4 | "embed" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/soulteary/webhook/internal/i18n" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | var embedFS embed.FS 14 | 15 | func TestLoadLocaleFiles(t *testing.T) { 16 | tempDir := t.TempDir() 17 | 18 | createTOMLFile(t, tempDir, "en.toml", ` 19 | [webhook] 20 | title = "Webhook" 21 | description = "This is a webhook" 22 | `) 23 | createTOMLFile(t, tempDir, "zh-CN.toml", ` 24 | [webhook] 25 | title = "网页钩子" 26 | description = "这是一个网页钩子" 27 | `) 28 | createTOMLFile(t, tempDir, "invalid.toml", ` 29 | invalid content 30 | `) 31 | 32 | aliveLocales := i18n.LoadLocaleFiles(tempDir, embedFS) 33 | assert.Len(t, aliveLocales, 2) 34 | 35 | assert.Equal(t, "en", aliveLocales[0].Name) 36 | assert.Contains(t, string(aliveLocales[0].Content), "Webhook") 37 | assert.Equal(t, "zh-CN", aliveLocales[1].Name) 38 | assert.Contains(t, string(aliveLocales[1].Content), "网页钩子") 39 | } 40 | 41 | func createTOMLFile(t *testing.T, dir, name, content string) { 42 | t.Helper() 43 | path := filepath.Join(dir, name) 44 | err := os.WriteFile(path, []byte(content), 0o644) 45 | assert.NoError(t, err) 46 | } 47 | 48 | func TestGetWebHookLocaleObject(t *testing.T) { 49 | locale, err := i18n.GetWebHookLocaleObject("en-US.toml", []byte{}) 50 | assert.NoError(t, err) 51 | assert.Equal(t, "en-US", locale.Name) 52 | 53 | _, err = i18n.GetWebHookLocaleObject("invalid.toml", []byte{}) 54 | assert.Error(t, err) 55 | assert.Contains(t, err.Error(), "invalid locale name") 56 | } 57 | -------------------------------------------------------------------------------- /internal/i18n/id.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | const ( 4 | MSG_WEBHOOK_VERSION = "WEBHOOK_VERSION" 5 | MSG_SETUID_OR_SETGID_ERROR = "ERROR_SETUID_OR_SETGID" 6 | MSG_SERVER_IS_STARTING = "SERVER_IS_STARTING" 7 | 8 | ERR_SERVER_LISTENING_PORT = "ERRPR_SERVER_LISTENING_PORT" 9 | ERR_SERVER_LISTENING_PRIVILEGES = "ERRPR_SERVER_LISTENING_PRIVILEGES" 10 | ERR_SERVER_OPENING_LOG_FILE = "ERROR_SERVER_OPENING_LOG_FILE" 11 | ERR_CREATING_PID_FILE = "ERROR_CREATING_PID_FILE" 12 | ERR_COULD_NOT_LOAD_ANY_HOOKS = "ERROR_COULD_NOT_LOAD_ANY_HOOKS" 13 | ) 14 | -------------------------------------------------------------------------------- /internal/link/link.go: -------------------------------------------------------------------------------- 1 | package link 2 | 3 | // makeRoutePattern builds a pattern matching URL for the mux. 4 | func MakeRoutePattern(prefix *string) string { 5 | return MakeBaseURL(prefix) + "/{id:.*}" 6 | } 7 | 8 | // makeHumanPattern builds a human-friendly URL for display. 9 | func MakeHumanPattern(prefix *string) string { 10 | return MakeBaseURL(prefix) + "/{id}" 11 | } 12 | 13 | // makeBaseURL creates the base URL before any mux pattern matching. 14 | func MakeBaseURL(prefix *string) string { 15 | if prefix == nil || *prefix == "" { 16 | return "" 17 | } 18 | 19 | return "/" + *prefix 20 | } 21 | -------------------------------------------------------------------------------- /internal/link/link_test.go: -------------------------------------------------------------------------------- 1 | package link_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/soulteary/webhook/internal/link" 7 | ) 8 | 9 | func TestMakeBaseURL(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | prefix *string 13 | want string 14 | }{ 15 | { 16 | name: "nil prefix", 17 | prefix: nil, 18 | want: "", 19 | }, 20 | { 21 | name: "empty prefix", 22 | prefix: new(string), 23 | want: "", 24 | }, 25 | { 26 | name: "non-empty prefix", 27 | prefix: newString("api"), 28 | want: "/api", 29 | }, 30 | } 31 | 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | if got := link.MakeBaseURL(tt.prefix); got != tt.want { 35 | t.Errorf("MakeBaseURL() = %v, want %v", got, tt.want) 36 | } 37 | }) 38 | } 39 | } 40 | 41 | func TestMakeRoutePattern(t *testing.T) { 42 | tests := []struct { 43 | name string 44 | prefix *string 45 | want string 46 | }{ 47 | { 48 | name: "nil prefix route pattern", 49 | prefix: nil, 50 | want: "/{id:.*}", 51 | }, 52 | { 53 | name: "empty prefix route pattern", 54 | prefix: new(string), 55 | want: "/{id:.*}", 56 | }, 57 | { 58 | name: "non-empty prefix route pattern", 59 | prefix: newString("api"), 60 | want: "/api/{id:.*}", 61 | }, 62 | } 63 | 64 | for _, tt := range tests { 65 | t.Run(tt.name, func(t *testing.T) { 66 | if got := link.MakeRoutePattern(tt.prefix); got != tt.want { 67 | t.Errorf("MakeRoutePattern() = %v, want %v", got, tt.want) 68 | } 69 | }) 70 | } 71 | } 72 | 73 | func TestMakeHumanPattern(t *testing.T) { 74 | tests := []struct { 75 | name string 76 | prefix *string 77 | want string 78 | }{ 79 | { 80 | name: "nil prefix human pattern", 81 | prefix: nil, 82 | want: "/{id}", 83 | }, 84 | { 85 | name: "empty prefix human pattern", 86 | prefix: new(string), 87 | want: "/{id}", 88 | }, 89 | { 90 | name: "non-empty prefix human pattern", 91 | prefix: newString("api"), 92 | want: "/api/{id}", 93 | }, 94 | } 95 | 96 | for _, tt := range tests { 97 | t.Run(tt.name, func(t *testing.T) { 98 | if got := link.MakeHumanPattern(tt.prefix); got != tt.want { 99 | t.Errorf("MakeHumanPattern() = %v, want %v", got, tt.want) 100 | } 101 | }) 102 | } 103 | } 104 | 105 | // newString is a helper function to create a pointer to a string. 106 | func newString(s string) *string { 107 | return &s 108 | } 109 | -------------------------------------------------------------------------------- /internal/middleware/dumper.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | // Derived from from the Goa project, MIT Licensed 4 | // https://github.com/goadesign/goa/blob/v3/http/middleware/debug.go 5 | 6 | import ( 7 | "bufio" 8 | "bytes" 9 | "fmt" 10 | "io" 11 | "net" 12 | "net/http" 13 | "net/http/httputil" 14 | "sort" 15 | "strings" 16 | ) 17 | 18 | // responseDupper tees the response to a buffer and a response writer. 19 | type responseDupper struct { 20 | http.ResponseWriter 21 | Buffer *bytes.Buffer 22 | Status int 23 | } 24 | 25 | // Dumper returns a debug middleware which prints detailed information about 26 | // incoming requests and outgoing responses including all headers, parameters 27 | // and bodies. 28 | func Dumper(w io.Writer) func(http.Handler) http.Handler { 29 | return func(h http.Handler) http.Handler { 30 | return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 31 | buf := &bytes.Buffer{} 32 | // Request ID 33 | rid := r.Context().Value(RequestIDKey) 34 | 35 | // Dump request 36 | 37 | bd, err := httputil.DumpRequest(r, true) 38 | if err != nil { 39 | buf.WriteString(fmt.Sprintf("[%s] Error dumping request for debugging: %s\n", rid, err)) 40 | } 41 | 42 | sc := bufio.NewScanner(bytes.NewBuffer(bd)) 43 | sc.Split(bufio.ScanLines) 44 | for sc.Scan() { 45 | buf.WriteString(fmt.Sprintf("> [%s] ", rid)) 46 | buf.WriteString(sc.Text() + "\n") 47 | } 48 | 49 | _, err = w.Write(buf.Bytes()) 50 | if err != nil { 51 | fmt.Println("Error writing to debug writer before buf reset: ", err) 52 | } 53 | buf.Reset() 54 | 55 | // Dump Response 56 | 57 | dupper := &responseDupper{ResponseWriter: rw, Buffer: &bytes.Buffer{}} 58 | h.ServeHTTP(dupper, r) 59 | 60 | // Response Status 61 | buf.WriteString(fmt.Sprintf("< [%s] %d %s\n", rid, dupper.Status, http.StatusText(dupper.Status))) 62 | 63 | // Response Headers 64 | keys := make([]string, len(dupper.Header())) 65 | i := 0 66 | for k := range dupper.Header() { 67 | keys[i] = k 68 | i++ 69 | } 70 | sort.Strings(keys) 71 | for _, k := range keys { 72 | buf.WriteString(fmt.Sprintf("< [%s] %s: %s\n", rid, k, strings.Join(dupper.Header()[k], ", "))) 73 | } 74 | 75 | // Response Body 76 | if dupper.Buffer.Len() > 0 { 77 | buf.WriteString(fmt.Sprintf("< [%s]\n", rid)) 78 | sc = bufio.NewScanner(dupper.Buffer) 79 | sc.Split(bufio.ScanLines) 80 | for sc.Scan() { 81 | buf.WriteString(fmt.Sprintf("< [%s] ", rid)) 82 | buf.WriteString(sc.Text() + "\n") 83 | } 84 | } 85 | _, err = w.Write(buf.Bytes()) 86 | if err != nil { 87 | fmt.Println("Error writing to debug writer: ", err) 88 | } 89 | }) 90 | } 91 | } 92 | 93 | // Write writes the data to the buffer and connection as part of an HTTP reply. 94 | func (r *responseDupper) Write(b []byte) (int, error) { 95 | r.Buffer.Write(b) 96 | return r.ResponseWriter.Write(b) 97 | } 98 | 99 | // WriteHeader records the status and sends an HTTP response header with status code. 100 | func (r *responseDupper) WriteHeader(s int) { 101 | r.Status = s 102 | r.ResponseWriter.WriteHeader(s) 103 | } 104 | 105 | // Hijack supports the http.Hijacker interface. 106 | func (r *responseDupper) Hijack() (net.Conn, *bufio.ReadWriter, error) { 107 | if hijacker, ok := r.ResponseWriter.(http.Hijacker); ok { 108 | return hijacker.Hijack() 109 | } 110 | return nil, nil, fmt.Errorf("dumper middleware: inner ResponseWriter cannot be hijacked: %T", r.ResponseWriter) 111 | } 112 | -------------------------------------------------------------------------------- /internal/middleware/logger.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/dustin/go-humanize" 11 | "github.com/go-chi/chi/v5/middleware" 12 | ) 13 | 14 | // Logger is a middleware that logs useful data about each HTTP request. 15 | type Logger struct { 16 | Logger middleware.LoggerInterface 17 | } 18 | 19 | // NewLogger creates a new RequestLogger Handler. 20 | func NewLogger() func(next http.Handler) http.Handler { 21 | return middleware.RequestLogger(&Logger{}) 22 | } 23 | 24 | // NewLogEntry creates a new LogEntry for the request. 25 | func (l *Logger) NewLogEntry(r *http.Request) middleware.LogEntry { 26 | e := &LogEntry{ 27 | req: r, 28 | buf: &bytes.Buffer{}, 29 | } 30 | 31 | return e 32 | } 33 | 34 | // LogEntry represents an individual log entry. 35 | type LogEntry struct { 36 | *Logger 37 | req *http.Request 38 | buf *bytes.Buffer 39 | } 40 | 41 | // Write constructs and writes the final log entry. 42 | func (l *LogEntry) Write(status int, bytes int, header http.Header, elapsed time.Duration, extra interface{}) { 43 | rid := GetReqID(l.req.Context()) 44 | if rid != "" { 45 | fmt.Fprintf(l.buf, "[%s] ", rid) 46 | } 47 | 48 | fmt.Fprintf(l.buf, "%03d | %s | %s | ", status, humanize.IBytes(uint64(bytes)), elapsed) 49 | l.buf.WriteString(l.req.Host + " | " + l.req.Method + " " + l.req.RequestURI) 50 | log.Print(l.buf.String()) 51 | } 52 | 53 | // Panic prints the call stack for a panic. 54 | func (l *LogEntry) Panic(v interface{}, stack []byte) { 55 | e := l.NewLogEntry(l.req).(*LogEntry) 56 | fmt.Fprintf(e.buf, "panic: %#v", v) 57 | log.Print(e.buf.String()) 58 | log.Print(string(stack)) 59 | } 60 | -------------------------------------------------------------------------------- /internal/middleware/request_id.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | // Derived from Goa project, MIT Licensed 4 | // https://github.com/goadesign/goa/blob/v3/http/middleware/requestid.go 5 | 6 | import ( 7 | "context" 8 | "net/http" 9 | 10 | "github.com/google/uuid" 11 | ) 12 | 13 | // Key to use when setting the request ID. 14 | type ctxKeyRequestID int 15 | 16 | // RequestIDKey is the key that holds the unique request ID in a request context. 17 | const RequestIDKey ctxKeyRequestID = 0 18 | 19 | // RequestID is a middleware that injects a request ID into the context of each 20 | // request. 21 | func RequestID(options ...RequestIDOption) func(http.Handler) http.Handler { 22 | o := newRequestIDOptions(options...) 23 | 24 | return func(next http.Handler) http.Handler { 25 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 26 | ctx := r.Context() 27 | 28 | var id string 29 | 30 | if o.UseRequestID() { 31 | id = r.Header.Get("X-Request-Id") 32 | if o.requestIDLimit > 0 && len(id) > o.requestIDLimit { 33 | id = id[:o.requestIDLimit] 34 | } 35 | } 36 | 37 | if id == "" { 38 | id = uuid.Must(uuid.NewRandom()).String()[:6] 39 | } 40 | 41 | ctx = context.WithValue(ctx, RequestIDKey, id) 42 | next.ServeHTTP(w, r.WithContext(ctx)) 43 | }) 44 | } 45 | } 46 | 47 | // GetReqID returns a request ID from the given context if one is present. 48 | // Returns the empty string if a request ID cannot be found. 49 | func GetReqID(ctx context.Context) string { 50 | if ctx == nil { 51 | return "" 52 | } 53 | if reqID, ok := ctx.Value(RequestIDKey).(string); ok { 54 | return reqID 55 | } 56 | return "" 57 | } 58 | 59 | func UseXRequestIDHeaderOption(f bool) RequestIDOption { 60 | return func(o *RequestIDOptions) *RequestIDOptions { 61 | o.useXRequestID = f 62 | return o 63 | } 64 | } 65 | 66 | func XRequestIDLimitOption(limit int) RequestIDOption { 67 | return func(o *RequestIDOptions) *RequestIDOptions { 68 | o.requestIDLimit = limit 69 | return o 70 | } 71 | } 72 | 73 | type ( 74 | RequestIDOption func(*RequestIDOptions) *RequestIDOptions 75 | 76 | RequestIDOptions struct { 77 | // useXRequestID enabled the use of the X-Request-Id request header as 78 | // the request ID. 79 | useXRequestID bool 80 | 81 | // requestIDLimit is the maximum length of the X-Request-Id header 82 | // allowed. Values longer than this value are truncated. Zero value 83 | // means no limit. 84 | requestIDLimit int 85 | } 86 | ) 87 | 88 | func newRequestIDOptions(options ...RequestIDOption) *RequestIDOptions { 89 | o := new(RequestIDOptions) 90 | for _, opt := range options { 91 | o = opt(o) 92 | } 93 | return o 94 | } 95 | 96 | func (o *RequestIDOptions) UseRequestID() bool { 97 | return o.useXRequestID 98 | } 99 | -------------------------------------------------------------------------------- /internal/monitor/monitor.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "time" 7 | 8 | "github.com/fsnotify/fsnotify" 9 | "github.com/soulteary/webhook/internal/rules" 10 | ) 11 | 12 | func WatchForFileChange(watcher *fsnotify.Watcher, asTemplate bool, verbose bool, noPanic bool, reloadHooks func(hooksFilePath string, asTemplate bool), removeHooks func(hooksFilePath string, verbose bool, noPanic bool)) { 13 | for { 14 | select { 15 | case event := <-(*watcher).Events: 16 | if event.Op&fsnotify.Write == fsnotify.Write { 17 | log.Printf("hooks file %s modified\n", event.Name) 18 | rules.ReloadHooks(event.Name, asTemplate) 19 | } else if event.Op&fsnotify.Remove == fsnotify.Remove { 20 | if _, err := os.Stat(event.Name); os.IsNotExist(err) { 21 | log.Printf("hooks file %s removed, no longer watching this file for changes, removing hooks that were loaded from it\n", event.Name) 22 | err = (*watcher).Remove(event.Name) 23 | if err != nil { 24 | log.Printf("error removing file %s from watcher: %s\n", event.Name, err) 25 | } 26 | rules.RemoveHooks(event.Name, verbose, noPanic) 27 | } 28 | } else if event.Op&fsnotify.Rename == fsnotify.Rename { 29 | time.Sleep(100 * time.Millisecond) 30 | if _, err := os.Stat(event.Name); os.IsNotExist(err) { 31 | // file was removed 32 | log.Printf("hooks file %s removed, no longer watching this file for changes, and removing hooks that were loaded from it\n", event.Name) 33 | err = (*watcher).Remove(event.Name) 34 | if err != nil { 35 | log.Printf("error removing file %s from watcher: %s\n", event.Name, err) 36 | } 37 | rules.RemoveHooks(event.Name, verbose, noPanic) 38 | } else { 39 | // file was overwritten 40 | log.Printf("hooks file %s overwritten\n", event.Name) 41 | rules.ReloadHooks(event.Name, asTemplate) 42 | err = (*watcher).Remove(event.Name) 43 | if err != nil { 44 | log.Printf("error removing file %s from watcher: %s\n", event.Name, err) 45 | } 46 | err = (*watcher).Add(event.Name) 47 | if err != nil { 48 | log.Printf("error adding file %s to watcher: %s\n", event.Name, err) 49 | } 50 | } 51 | } 52 | case err := <-(*watcher).Errors: 53 | log.Println("watcher error:", err) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/monitor/watcher.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/fsnotify/fsnotify" 7 | "github.com/soulteary/webhook/internal/flags" 8 | "github.com/soulteary/webhook/internal/rules" 9 | ) 10 | 11 | var watcher *fsnotify.Watcher 12 | 13 | func ApplyWatcher(appFlags flags.AppFlags) { 14 | var err error 15 | watcher, err = fsnotify.NewWatcher() 16 | if err != nil { 17 | log.Fatal("error creating file watcher instance\n", err) 18 | } 19 | defer watcher.Close() 20 | 21 | for _, hooksFilePath := range rules.HooksFiles { 22 | // set up file watcher 23 | log.Printf("setting up file watcher for %s\n", hooksFilePath) 24 | 25 | err = watcher.Add(hooksFilePath) 26 | if err != nil { 27 | log.Print("error adding hooks file to the watcher\n", err) 28 | return 29 | } 30 | } 31 | 32 | go WatchForFileChange(watcher, appFlags.AsTemplate, appFlags.Verbose, appFlags.NoPanic, rules.ReloadHooks, rules.RemoveHooks) 33 | } 34 | -------------------------------------------------------------------------------- /internal/pidfile/README.md: -------------------------------------------------------------------------------- 1 | Package pidfile is derived from github.com/moby/moby/pkg/pidfile. 2 | 3 | Moby is licensed under the Apache License, Version 2.0. 4 | Copyright 2012-2017 Docker, Inc. 5 | -------------------------------------------------------------------------------- /internal/pidfile/mkdirall.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package pidfile 5 | 6 | import "os" 7 | 8 | // MkdirAll creates a directory named path along with any necessary parents, 9 | // with permission specified by attribute perm for all dir created. 10 | func MkdirAll(path string, perm os.FileMode) error { 11 | return os.MkdirAll(path, perm) 12 | } 13 | -------------------------------------------------------------------------------- /internal/pidfile/mkdirall_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package pidfile 5 | 6 | import ( 7 | "os" 8 | "regexp" 9 | "syscall" 10 | "unsafe" 11 | 12 | "golang.org/x/sys/windows" 13 | ) 14 | 15 | // MkdirAll implementation that is volume path aware for Windows. It can be used 16 | // as a drop-in replacement for os.MkdirAll() 17 | func MkdirAll(path string, _ os.FileMode) error { 18 | return mkdirall(path, false, "") 19 | } 20 | 21 | // mkdirall is a custom version of os.MkdirAll modified for use on Windows 22 | // so that it is both volume path aware, and can create a directory with 23 | // a DACL. 24 | func mkdirall(path string, applyACL bool, sddl string) error { 25 | if re := regexp.MustCompile(`^\\\\\?\\Volume{[a-z0-9-]+}$`); re.MatchString(path) { 26 | return nil 27 | } 28 | 29 | // The rest of this method is largely copied from os.MkdirAll and should be kept 30 | // as-is to ensure compatibility. 31 | 32 | // Fast path: if we can tell whether path is a directory or file, stop with success or error. 33 | dir, err := os.Stat(path) 34 | if err == nil { 35 | if dir.IsDir() { 36 | return nil 37 | } 38 | return &os.PathError{ 39 | Op: "mkdir", 40 | Path: path, 41 | Err: syscall.ENOTDIR, 42 | } 43 | } 44 | 45 | // Slow path: make sure parent exists and then call Mkdir for path. 46 | i := len(path) 47 | for i > 0 && os.IsPathSeparator(path[i-1]) { // Skip trailing path separator. 48 | i-- 49 | } 50 | 51 | j := i 52 | for j > 0 && !os.IsPathSeparator(path[j-1]) { // Scan backward over element. 53 | j-- 54 | } 55 | 56 | if j > 1 { 57 | // Create parent 58 | err = mkdirall(path[0:j-1], false, sddl) 59 | if err != nil { 60 | return err 61 | } 62 | } 63 | 64 | // Parent now exists; invoke os.Mkdir or mkdirWithACL and use its result. 65 | if applyACL { 66 | err = mkdirWithACL(path, sddl) 67 | } else { 68 | err = os.Mkdir(path, 0) 69 | } 70 | 71 | if err != nil { 72 | // Handle arguments like "foo/." by 73 | // double-checking that directory doesn't exist. 74 | dir, err1 := os.Lstat(path) 75 | if err1 == nil && dir.IsDir() { 76 | return nil 77 | } 78 | return err 79 | } 80 | return nil 81 | } 82 | 83 | // mkdirWithACL creates a new directory. If there is an error, it will be of 84 | // type *PathError. . 85 | // 86 | // This is a modified and combined version of os.Mkdir and windows.Mkdir 87 | // in golang to cater for creating a directory am ACL permitting full 88 | // access, with inheritance, to any subfolder/file for Built-in Administrators 89 | // and Local System. 90 | func mkdirWithACL(name string, sddl string) error { 91 | sa := windows.SecurityAttributes{Length: 0} 92 | sd, err := windows.SecurityDescriptorFromString(sddl) 93 | if err != nil { 94 | return &os.PathError{Op: "mkdir", Path: name, Err: err} 95 | } 96 | sa.Length = uint32(unsafe.Sizeof(sa)) 97 | sa.InheritHandle = 1 98 | sa.SecurityDescriptor = sd 99 | 100 | namep, err := windows.UTF16PtrFromString(name) 101 | if err != nil { 102 | return &os.PathError{Op: "mkdir", Path: name, Err: err} 103 | } 104 | 105 | e := windows.CreateDirectory(namep, &sa) 106 | if e != nil { 107 | return &os.PathError{Op: "mkdir", Path: name, Err: e} 108 | } 109 | return nil 110 | } 111 | -------------------------------------------------------------------------------- /internal/pidfile/pidfile.go: -------------------------------------------------------------------------------- 1 | // Package pidfile provides structure and helper functions to create and remove 2 | // PID file. A PID file is usually a file used to store the process ID of a 3 | // running process. 4 | package pidfile 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | // PIDFile is a file used to store the process ID of a running process. 15 | type PIDFile struct { 16 | path string 17 | } 18 | 19 | func checkPIDFileAlreadyExists(path string) error { 20 | if pidByte, err := os.ReadFile(filepath.Clean(path)); err == nil { 21 | pidString := strings.TrimSpace(string(pidByte)) 22 | if pid, err := strconv.Atoi(pidString); err == nil { 23 | if processExists(pid) { 24 | return fmt.Errorf("pid file found, ensure webhook is not running or delete %s", path) 25 | } 26 | } 27 | } 28 | return nil 29 | } 30 | 31 | // New creates a PIDfile using the specified path. 32 | func New(path string) (*PIDFile, error) { 33 | if err := checkPIDFileAlreadyExists(path); err != nil { 34 | return nil, err 35 | } 36 | // Note MkdirAll returns nil if a directory already exists 37 | if err := MkdirAll(filepath.Dir(path), os.FileMode(0o755)); err != nil { 38 | return nil, err 39 | } 40 | if err := os.WriteFile(path, []byte(fmt.Sprintf("%d", os.Getpid())), 0o600); err != nil { 41 | return nil, err 42 | } 43 | 44 | return &PIDFile{path: path}, nil 45 | } 46 | 47 | // Remove removes the PIDFile. 48 | func (file PIDFile) Remove() error { 49 | return os.Remove(file.path) 50 | } 51 | -------------------------------------------------------------------------------- /internal/pidfile/pidfile_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | // +build darwin 3 | 4 | package pidfile 5 | 6 | import ( 7 | "golang.org/x/sys/unix" 8 | ) 9 | 10 | func processExists(pid int) bool { 11 | // OS X does not have a proc filesystem. 12 | // Use kill -0 pid to judge if the process exists. 13 | err := unix.Kill(pid, 0) 14 | return err == nil 15 | } 16 | -------------------------------------------------------------------------------- /internal/pidfile/pidfile_test.go: -------------------------------------------------------------------------------- 1 | package pidfile 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | func TestNewAndRemove(t *testing.T) { 10 | dir, err := os.MkdirTemp(os.TempDir(), "test-pidfile") 11 | if err != nil { 12 | t.Fatal("Could not create test directory") 13 | } 14 | 15 | path := filepath.Join(dir, "testfile") 16 | file, err := New(path) 17 | if err != nil { 18 | t.Fatal("Could not create test file", err) 19 | } 20 | 21 | _, err = New(path) 22 | if err == nil { 23 | t.Fatal("Test file creation not blocked") 24 | } 25 | 26 | if err := file.Remove(); err != nil { 27 | t.Fatal("Could not delete created test file") 28 | } 29 | } 30 | 31 | func TestRemoveInvalidPath(t *testing.T) { 32 | file := PIDFile{path: filepath.Join("foo", "bar")} 33 | 34 | if err := file.Remove(); err == nil { 35 | t.Fatal("Non-existing file doesn't give an error on delete") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /internal/pidfile/pidfile_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !darwin 2 | // +build !windows,!darwin 3 | 4 | package pidfile 5 | 6 | import ( 7 | "os" 8 | "path/filepath" 9 | "strconv" 10 | ) 11 | 12 | func processExists(pid int) bool { 13 | if _, err := os.Stat(filepath.Join("/proc", strconv.Itoa(pid))); err == nil { 14 | return true 15 | } 16 | return false 17 | } 18 | -------------------------------------------------------------------------------- /internal/pidfile/pidfile_windows.go: -------------------------------------------------------------------------------- 1 | package pidfile 2 | 3 | import ( 4 | "golang.org/x/sys/windows" 5 | ) 6 | 7 | const ( 8 | processQueryLimitedInformation = 0x1000 9 | 10 | stillActive = 259 11 | ) 12 | 13 | func processExists(pid int) bool { 14 | h, err := windows.OpenProcess(processQueryLimitedInformation, false, uint32(pid)) 15 | if err != nil { 16 | return false 17 | } 18 | var c uint32 19 | err = windows.GetExitCodeProcess(h, &c) 20 | windows.Close(h) 21 | if err != nil { 22 | return c == stillActive 23 | } 24 | return true 25 | } 26 | -------------------------------------------------------------------------------- /internal/platform/droppriv_nope.go: -------------------------------------------------------------------------------- 1 | //go:build linux || windows 2 | // +build linux windows 3 | 4 | package platform 5 | 6 | import ( 7 | "errors" 8 | "runtime" 9 | ) 10 | 11 | func DropPrivileges(uid, gid int) error { 12 | return errors.New("setuid and setgid not supported on " + runtime.GOOS) 13 | } 14 | -------------------------------------------------------------------------------- /internal/platform/droppriv_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !linux 2 | // +build !windows,!linux 3 | 4 | package platform 5 | 6 | import ( 7 | "syscall" 8 | ) 9 | 10 | func DropPrivileges(uid, gid int) error { 11 | err := syscall.Setgid(gid) 12 | if err != nil { 13 | return err 14 | } 15 | 16 | err = syscall.Setuid(uid) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /internal/platform/signals.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package platform 5 | 6 | import ( 7 | "log" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | 12 | "github.com/soulteary/webhook/internal/pidfile" 13 | ) 14 | 15 | func SetupSignals(signals chan os.Signal, reloadFn func(), pidFile *pidfile.PIDFile) { 16 | log.Printf("setting up os signal watcher\n") 17 | 18 | signals = make(chan os.Signal, 1) 19 | signal.Notify(signals, syscall.SIGUSR1) 20 | signal.Notify(signals, syscall.SIGHUP) 21 | signal.Notify(signals, syscall.SIGTERM) 22 | signal.Notify(signals, os.Interrupt) 23 | 24 | go watchForSignals(signals, reloadFn, pidFile) 25 | } 26 | 27 | func watchForSignals(signals chan os.Signal, reloadFn func(), pidFile *pidfile.PIDFile) { 28 | log.Println("os signal watcher ready") 29 | 30 | for { 31 | sig := <-signals 32 | switch sig { 33 | case syscall.SIGUSR1: 34 | log.Println("caught USR1 signal") 35 | reloadFn() 36 | 37 | case syscall.SIGHUP: 38 | log.Println("caught HUP signal") 39 | reloadFn() 40 | 41 | case os.Interrupt, syscall.SIGTERM: 42 | log.Printf("caught %s signal; exiting\n", sig) 43 | if pidFile != nil { 44 | err := pidFile.Remove() 45 | if err != nil { 46 | log.Print(err) 47 | } 48 | } 49 | os.Exit(0) 50 | 51 | default: 52 | log.Printf("caught unhandled signal %+v\n", sig) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/platform/signals_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package platform 5 | 6 | func SetupSignals() { 7 | // NOOP: Windows doesn't have signals equivalent to the Unix world. 8 | } 9 | -------------------------------------------------------------------------------- /internal/rules/parse.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/soulteary/webhook/internal/hook" 7 | ) 8 | 9 | func ParseAndLoadHooks(isAsTemplate bool) { 10 | // load and parse hooks 11 | for _, hooksFilePath := range HooksFiles { 12 | log.Printf("attempting to load hooks from %s\n", hooksFilePath) 13 | 14 | newHooks := hook.Hooks{} 15 | 16 | err := newHooks.LoadFromFile(hooksFilePath, isAsTemplate) 17 | if err != nil { 18 | log.Printf("couldn't load hooks from file! %+v\n", err) 19 | } else { 20 | log.Printf("found %d hook(s) in file\n", len(newHooks)) 21 | 22 | for _, hook := range newHooks { 23 | if MatchLoadedHook(hook.ID) != nil { 24 | log.Fatalf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!\n", hook.ID) 25 | } 26 | log.Printf("\tloaded: %s\n", hook.ID) 27 | } 28 | 29 | LoadedHooksFromFiles[hooksFilePath] = newHooks 30 | } 31 | } 32 | 33 | newHooksFiles := HooksFiles[:0] 34 | for _, filePath := range HooksFiles { 35 | if _, ok := LoadedHooksFromFiles[filePath]; ok { 36 | newHooksFiles = append(newHooksFiles, filePath) 37 | } 38 | } 39 | 40 | HooksFiles = newHooksFiles 41 | } 42 | -------------------------------------------------------------------------------- /internal/rules/rules.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/soulteary/webhook/internal/hook" 8 | ) 9 | 10 | var ( 11 | LoadedHooksFromFiles = make(map[string]hook.Hooks) 12 | HooksFiles hook.HooksFiles 13 | ) 14 | 15 | func RemoveHooks(hooksFilePath string, verbose bool, noPanic bool) { 16 | for _, hook := range LoadedHooksFromFiles[hooksFilePath] { 17 | log.Printf("\tremoving: %s\n", hook.ID) 18 | } 19 | 20 | newHooksFiles := HooksFiles[:0] 21 | for _, filePath := range HooksFiles { 22 | if filePath != hooksFilePath { 23 | newHooksFiles = append(newHooksFiles, filePath) 24 | } 25 | } 26 | 27 | HooksFiles = newHooksFiles 28 | 29 | removedHooksCount := len(LoadedHooksFromFiles[hooksFilePath]) 30 | 31 | delete(LoadedHooksFromFiles, hooksFilePath) 32 | 33 | log.Printf("removed %d hook(s) that were loaded from file %s\n", removedHooksCount, hooksFilePath) 34 | 35 | if !verbose && !noPanic && LenLoadedHooks() == 0 { 36 | log.SetOutput(os.Stdout) 37 | 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") 38 | } 39 | } 40 | 41 | func LenLoadedHooks() int { 42 | sum := 0 43 | for _, hooks := range LoadedHooksFromFiles { 44 | sum += len(hooks) 45 | } 46 | 47 | return sum 48 | } 49 | 50 | func MatchLoadedHook(id string) *hook.Hook { 51 | for _, hooks := range LoadedHooksFromFiles { 52 | if hook := hooks.Match(id); hook != nil { 53 | return hook 54 | } 55 | } 56 | 57 | return nil 58 | } 59 | 60 | func ReloadHooks(hooksFilePath string, asTemplate bool) { 61 | hooksInFile := hook.Hooks{} 62 | 63 | // parse and swap 64 | log.Printf("attempting to reload hooks from %s\n", hooksFilePath) 65 | 66 | err := hooksInFile.LoadFromFile(hooksFilePath, asTemplate) 67 | 68 | if err != nil { 69 | log.Printf("couldn't load hooks from file! %+v\n", err) 70 | } else { 71 | seenHooksIds := make(map[string]bool) 72 | 73 | log.Printf("found %d hook(s) in file\n", len(hooksInFile)) 74 | 75 | for _, hook := range hooksInFile { 76 | wasHookIDAlreadyLoaded := false 77 | 78 | for _, loadedHook := range LoadedHooksFromFiles[hooksFilePath] { 79 | if loadedHook.ID == hook.ID { 80 | wasHookIDAlreadyLoaded = true 81 | break 82 | } 83 | } 84 | 85 | if (MatchLoadedHook(hook.ID) != nil && !wasHookIDAlreadyLoaded) || seenHooksIds[hook.ID] { 86 | log.Printf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!", hook.ID) 87 | log.Println("reverting hooks back to the previous configuration") 88 | return 89 | } 90 | 91 | seenHooksIds[hook.ID] = true 92 | log.Printf("\tloaded: %s\n", hook.ID) 93 | } 94 | 95 | LoadedHooksFromFiles[hooksFilePath] = hooksInFile 96 | } 97 | } 98 | 99 | func reloadAllHooks(asTemplate bool) { 100 | for _, hooksFilePath := range HooksFiles { 101 | ReloadHooks(hooksFilePath, asTemplate) 102 | } 103 | } 104 | 105 | func ReloadAllHooksAsTemplate() { 106 | reloadAllHooks(true) 107 | } 108 | 109 | func ReloadAllHooksNotAsTemplate() { 110 | reloadAllHooks(false) 111 | } 112 | -------------------------------------------------------------------------------- /internal/rules/rules_test.go: -------------------------------------------------------------------------------- 1 | package rules_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/soulteary/webhook/internal/hook" 7 | "github.com/soulteary/webhook/internal/rules" 8 | "github.com/stretchr/testify/assert" // You might want to get this assertion library for convenience. 9 | ) 10 | 11 | func TestRemoveHooks(t *testing.T) { 12 | // Setup 13 | rules.HooksFiles = []string{"test1.json", "test2.json"} 14 | rules.LoadedHooksFromFiles = map[string]hook.Hooks{ 15 | "test1.json": {{ID: "hook1"}}, 16 | "test2.json": {{ID: "hook2"}}, 17 | } 18 | 19 | // Execute 20 | rules.RemoveHooks("test1.json", false, false) 21 | 22 | // Assert 23 | assert.Equal(t, 1, rules.LenLoadedHooks(), "Expected number of hooks after removing should be 1") 24 | assert.Nil(t, rules.LoadedHooksFromFiles["test1.json"], "Expected test1.json hooks to be removed") 25 | assert.Contains(t, rules.HooksFiles, "test2.json", "HooksFiles should still contain 'test2.json'") 26 | } 27 | 28 | func TestLenLoadedHooks(t *testing.T) { 29 | // Setup 30 | rules.LoadedHooksFromFiles = map[string]hook.Hooks{ 31 | "test1.json": {{ID: "hook1"}, {ID: "hook2"}}, 32 | "test2.json": {{ID: "hook3"}}, 33 | } 34 | 35 | // Execute 36 | length := rules.LenLoadedHooks() 37 | 38 | // Assert 39 | assert.Equal(t, 3, length, "Expected total length of all loaded hooks to be 3") 40 | } 41 | 42 | func TestMatchLoadedHook(t *testing.T) { 43 | // Setup 44 | rules.LoadedHooksFromFiles = map[string]hook.Hooks{ 45 | "test1.json": {{ID: "hook1"}, {ID: "hook2"}}, 46 | } 47 | 48 | // Tests 49 | tests := []struct { 50 | id string 51 | expected bool 52 | }{ 53 | {"hook1", true}, 54 | {"hook2", true}, 55 | {"nonexistent", false}, 56 | } 57 | 58 | for _, test := range tests { 59 | t.Run(test.id, func(t *testing.T) { 60 | // Execute 61 | match := rules.MatchLoadedHook(test.id) 62 | 63 | // Assert 64 | if test.expected { 65 | assert.NotNil(t, match, "Expected to find hook with id %s", test.id) 66 | } else { 67 | assert.Nil(t, match, "Expected to not find hook with id %s", test.id) 68 | } 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/gorilla/mux" 15 | "github.com/soulteary/webhook/internal/flags" 16 | "github.com/soulteary/webhook/internal/fn" 17 | "github.com/soulteary/webhook/internal/hook" 18 | "github.com/soulteary/webhook/internal/middleware" 19 | "github.com/soulteary/webhook/internal/rules" 20 | ) 21 | 22 | type flushWriter struct { 23 | f http.Flusher 24 | w io.Writer 25 | } 26 | 27 | func (fw *flushWriter) Write(p []byte) (n int, err error) { 28 | n, err = fw.w.Write(p) 29 | if fw.f != nil { 30 | fw.f.Flush() 31 | } 32 | return 33 | } 34 | 35 | func createHookHandler(appFlags flags.AppFlags) func(w http.ResponseWriter, r *http.Request) { 36 | return func(w http.ResponseWriter, r *http.Request) { 37 | requestID := middleware.GetReqID(r.Context()) 38 | req := &hook.Request{ 39 | ID: requestID, 40 | RawRequest: r, 41 | } 42 | 43 | log.Printf("[%s] incoming HTTP %s request from %s\n", requestID, r.Method, r.RemoteAddr) 44 | 45 | hookID := strings.TrimSpace(mux.Vars(r)["id"]) 46 | hookID = fn.RemoveNewlinesAndTabs(hookID) 47 | 48 | matchedHook := rules.MatchLoadedHook(hookID) 49 | if matchedHook == nil { 50 | w.WriteHeader(http.StatusNotFound) 51 | fmt.Fprint(w, "Hook not found.") 52 | return 53 | } 54 | 55 | // Check for allowed methods 56 | var allowedMethod bool 57 | 58 | switch { 59 | case len(matchedHook.HTTPMethods) != 0: 60 | for i := range matchedHook.HTTPMethods { 61 | // TODO(moorereason): refactor config loading and reloading to 62 | // sanitize these methods once at load time. 63 | if r.Method == strings.ToUpper(strings.TrimSpace(matchedHook.HTTPMethods[i])) { 64 | allowedMethod = true 65 | break 66 | } 67 | } 68 | case appFlags.HttpMethods != "": 69 | for _, v := range strings.Split(appFlags.HttpMethods, ",") { 70 | if r.Method == v { 71 | allowedMethod = true 72 | break 73 | } 74 | } 75 | default: 76 | allowedMethod = true 77 | } 78 | 79 | if !allowedMethod { 80 | w.WriteHeader(http.StatusMethodNotAllowed) 81 | log.Printf("[%s] HTTP %s method not allowed for hook %q", requestID, r.Method, hookID) 82 | 83 | return 84 | } 85 | 86 | log.Printf("[%s] %s got matched\n", requestID, hookID) 87 | 88 | for _, responseHeader := range appFlags.ResponseHeaders { 89 | w.Header().Set(responseHeader.Name, responseHeader.Value) 90 | } 91 | 92 | var err error 93 | 94 | // set contentType to IncomingPayloadContentType or header value 95 | req.ContentType = r.Header.Get("Content-Type") 96 | if len(matchedHook.IncomingPayloadContentType) != 0 { 97 | req.ContentType = matchedHook.IncomingPayloadContentType 98 | } 99 | 100 | isMultipart := strings.HasPrefix(req.ContentType, "multipart/form-data;") 101 | 102 | if !isMultipart { 103 | req.Body, err = io.ReadAll(r.Body) 104 | if err != nil { 105 | log.Printf("[%s] error reading the request body: %+v\n", requestID, err) 106 | } 107 | } 108 | 109 | req.ParseHeaders(r.Header) 110 | req.ParseQuery(r.URL.Query()) 111 | 112 | switch { 113 | case strings.Contains(req.ContentType, "json"): 114 | err = req.ParseJSONPayload() 115 | if err != nil { 116 | log.Printf("[%s] %s", requestID, err) 117 | } 118 | 119 | case strings.Contains(req.ContentType, "x-www-form-urlencoded"): 120 | err = req.ParseFormPayload() 121 | if err != nil { 122 | log.Printf("[%s] %s", requestID, err) 123 | } 124 | 125 | case strings.Contains(req.ContentType, "xml"): 126 | err = req.ParseXMLPayload() 127 | if err != nil { 128 | log.Printf("[%s] %s", requestID, err) 129 | } 130 | 131 | case isMultipart: 132 | err = r.ParseMultipartForm(appFlags.MaxMultipartMem) 133 | if err != nil { 134 | msg := fmt.Sprintf("[%s] error parsing multipart form: %+v\n", requestID, err) 135 | log.Println(msg) 136 | w.WriteHeader(http.StatusInternalServerError) 137 | fmt.Fprint(w, "Error occurred while parsing multipart form.") 138 | return 139 | } 140 | 141 | for k, v := range r.MultipartForm.Value { 142 | log.Printf("[%s] found multipart form value %q", requestID, k) 143 | 144 | if req.Payload == nil { 145 | req.Payload = make(map[string]interface{}) 146 | } 147 | 148 | // TODO(moorereason): support duplicate, named values 149 | req.Payload[k] = v[0] 150 | } 151 | 152 | for k, v := range r.MultipartForm.File { 153 | // Force parsing as JSON regardless of Content-Type. 154 | var parseAsJSON bool 155 | for _, j := range matchedHook.JSONStringParameters { 156 | if j.Source == "payload" && j.Name == k { 157 | parseAsJSON = true 158 | break 159 | } 160 | } 161 | 162 | // TODO(moorereason): we need to support multiple parts 163 | // with the same name instead of just processing the first 164 | // one. Will need #215 resolved first. 165 | 166 | // MIME encoding can contain duplicate headers, so check them 167 | // all. 168 | if !parseAsJSON && len(v[0].Header["Content-Type"]) > 0 { 169 | for _, j := range v[0].Header["Content-Type"] { 170 | if j == "application/json" { 171 | parseAsJSON = true 172 | break 173 | } 174 | } 175 | } 176 | 177 | if parseAsJSON { 178 | log.Printf("[%s] parsing multipart form file %q as JSON\n", requestID, k) 179 | 180 | f, err := v[0].Open() 181 | if err != nil { 182 | msg := fmt.Sprintf("[%s] error parsing multipart form file: %+v\n", requestID, err) 183 | log.Println(msg) 184 | w.WriteHeader(http.StatusInternalServerError) 185 | fmt.Fprint(w, "Error occurred while parsing multipart form file.") 186 | return 187 | } 188 | 189 | decoder := json.NewDecoder(f) 190 | decoder.UseNumber() 191 | 192 | var part map[string]interface{} 193 | err = decoder.Decode(&part) 194 | if err != nil { 195 | log.Printf("[%s] error parsing JSON payload file: %+v\n", requestID, err) 196 | } 197 | 198 | if req.Payload == nil { 199 | req.Payload = make(map[string]interface{}) 200 | } 201 | req.Payload[k] = part 202 | } 203 | } 204 | 205 | default: 206 | logContent := fmt.Sprintf("[%s] error parsing body payload due to unsupported content type header: %s\n", requestID, req.ContentType) 207 | log.Println(fn.RemoveNewlinesAndTabs(logContent)) 208 | } 209 | 210 | // handle hook 211 | errors := matchedHook.ParseJSONParameters(req) 212 | for _, err := range errors { 213 | log.Printf("[%s] error parsing JSON parameters: %s\n", requestID, err) 214 | } 215 | 216 | var ok bool 217 | 218 | if matchedHook.TriggerRule == nil { 219 | ok = true 220 | } else { 221 | // Save signature soft failures option in request for evaluators 222 | req.AllowSignatureErrors = matchedHook.TriggerSignatureSoftFailures 223 | 224 | ok, err = matchedHook.TriggerRule.Evaluate(req) 225 | if err != nil { 226 | if !hook.IsParameterNodeError(err) { 227 | msg := fmt.Sprintf("[%s] error evaluating hook: %s", requestID, err) 228 | log.Println(msg) 229 | w.WriteHeader(http.StatusInternalServerError) 230 | fmt.Fprint(w, "Error occurred while evaluating hook rules.") 231 | return 232 | } 233 | 234 | log.Printf("[%s] %v", requestID, err) 235 | } 236 | } 237 | 238 | if ok { 239 | log.Printf("[%s] %s hook triggered successfully\n", requestID, matchedHook.ID) 240 | 241 | for _, responseHeader := range matchedHook.ResponseHeaders { 242 | w.Header().Set(responseHeader.Name, responseHeader.Value) 243 | } 244 | 245 | if matchedHook.StreamCommandOutput { 246 | _, err := handleHook(matchedHook, req, w) 247 | if err != nil { 248 | fmt.Fprint(w, "Error occurred while executing the hook's stream command. Please check your logs for more details.") 249 | } 250 | } else if matchedHook.CaptureCommandOutput { 251 | response, err := handleHook(matchedHook, req, nil) 252 | 253 | if err != nil { 254 | w.WriteHeader(http.StatusInternalServerError) 255 | if matchedHook.CaptureCommandOutputOnError { 256 | fmt.Fprint(w, response) 257 | } else { 258 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 259 | fmt.Fprint(w, "Error occurred while executing the hook's command. Please check your logs for more details.") 260 | } 261 | } else { 262 | // Check if a success return code is configured for the hook 263 | if matchedHook.SuccessHttpResponseCode != 0 { 264 | writeHttpResponseCode(w, requestID, matchedHook.ID, matchedHook.SuccessHttpResponseCode) 265 | } 266 | fmt.Fprint(w, response) 267 | } 268 | } else { 269 | go handleHook(matchedHook, req, nil) 270 | 271 | // Check if a success return code is configured for the hook 272 | if matchedHook.SuccessHttpResponseCode != 0 { 273 | writeHttpResponseCode(w, requestID, matchedHook.ID, matchedHook.SuccessHttpResponseCode) 274 | } 275 | 276 | fmt.Fprint(w, matchedHook.ResponseMessage) 277 | } 278 | return 279 | } 280 | 281 | // Check if a return code is configured for the hook 282 | if matchedHook.TriggerRuleMismatchHttpResponseCode != 0 { 283 | writeHttpResponseCode(w, requestID, matchedHook.ID, matchedHook.TriggerRuleMismatchHttpResponseCode) 284 | } 285 | 286 | // if none of the hooks got triggered 287 | log.Printf("[%s] %s got matched, but didn't get triggered because the trigger rules were not satisfied\n", requestID, matchedHook.ID) 288 | 289 | fmt.Fprint(w, "Hook rules were not satisfied.") 290 | } 291 | } 292 | 293 | func makeSureCallable(h *hook.Hook, r *hook.Request) (string, error) { 294 | // check the command exists 295 | var lookpath string 296 | if filepath.IsAbs(h.ExecuteCommand) || h.CommandWorkingDirectory == "" { 297 | lookpath = h.ExecuteCommand 298 | } else { 299 | lookpath = filepath.Join(h.CommandWorkingDirectory, h.ExecuteCommand) 300 | } 301 | 302 | cmdPath, err := exec.LookPath(lookpath) 303 | if err != nil { 304 | log.Printf("[%s] error in %s", r.ID, err) 305 | 306 | if strings.Contains(err.Error(), "permission denied") { 307 | // try to make the command executable 308 | // #nosec 309 | err2 := os.Chmod(lookpath, 0o755) 310 | if err2 != nil { 311 | log.Printf("[%s] make command script executable error in %s", r.ID, err2) 312 | 313 | return "", err 314 | } 315 | 316 | log.Printf("[%s] make command script executable success", r.ID) 317 | // retry 318 | return makeSureCallable(h, r) 319 | } 320 | 321 | // check if parameters specified in execute-command by mistake 322 | if strings.IndexByte(h.ExecuteCommand, ' ') != -1 { 323 | s := strings.Fields(h.ExecuteCommand)[0] 324 | log.Printf("[%s] use 'pass-arguments-to-command' to specify args for '%s'", r.ID, s) 325 | } 326 | 327 | return "", err 328 | } 329 | 330 | return cmdPath, nil 331 | } 332 | 333 | func handleHook(h *hook.Hook, r *hook.Request, w http.ResponseWriter) (string, error) { 334 | var errors []error 335 | 336 | cmdPath, err := makeSureCallable(h, r) 337 | if err != nil { 338 | return "", err 339 | } 340 | 341 | cmd := exec.Command(cmdPath) 342 | cmd.Dir = h.CommandWorkingDirectory 343 | 344 | cmd.Args, errors = h.ExtractCommandArguments(r) 345 | for _, err := range errors { 346 | log.Printf("[%s] error extracting command arguments: %s\n", r.ID, err) 347 | } 348 | 349 | var envs []string 350 | envs, errors = h.ExtractCommandArgumentsForEnv(r) 351 | 352 | for _, err := range errors { 353 | log.Printf("[%s] error extracting command arguments for environment: %s\n", r.ID, err) 354 | } 355 | 356 | files, errors := h.ExtractCommandArgumentsForFile(r) 357 | 358 | for _, err := range errors { 359 | log.Printf("[%s] error extracting command arguments for file: %s\n", r.ID, err) 360 | } 361 | 362 | for i := range files { 363 | tmpfile, err := os.CreateTemp(h.CommandWorkingDirectory, files[i].EnvName) 364 | if err != nil { 365 | log.Printf("[%s] error creating temp file [%s]", r.ID, err) 366 | continue 367 | } 368 | log.Printf("[%s] writing env %s file %s", r.ID, files[i].EnvName, tmpfile.Name()) 369 | if _, err := tmpfile.Write(files[i].Data); err != nil { 370 | log.Printf("[%s] error writing file %s [%s]", r.ID, tmpfile.Name(), err) 371 | continue 372 | } 373 | if err := tmpfile.Close(); err != nil { 374 | log.Printf("[%s] error closing file %s [%s]", r.ID, tmpfile.Name(), err) 375 | continue 376 | } 377 | 378 | files[i].File = tmpfile 379 | envs = append(envs, files[i].EnvName+"="+tmpfile.Name()) 380 | } 381 | 382 | cmd.Env = append(os.Environ(), envs...) 383 | 384 | logsContent := fmt.Sprintf("[%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) 385 | log.Println(fn.RemoveNewlinesAndTabs(logsContent)) 386 | 387 | var out []byte 388 | if w != nil { 389 | log.Printf("[%s] command output will be streamed to response", r.ID) 390 | 391 | // Implementation from https://play.golang.org/p/PpbPyXbtEs 392 | // as described in https://stackoverflow.com/questions/19292113/not-buffered-http-responsewritter-in-golang 393 | fw := flushWriter{w: w} 394 | if f, ok := w.(http.Flusher); ok { 395 | fw.f = f 396 | } 397 | cmd.Stderr = &fw 398 | cmd.Stdout = &fw 399 | 400 | if err := cmd.Run(); err != nil { 401 | log.Printf("[%s] error occurred: %+v\n", r.ID, err) 402 | } 403 | } else { 404 | out, err = cmd.CombinedOutput() 405 | 406 | log.Printf("[%s] command output: %s\n", r.ID, out) 407 | 408 | if err != nil { 409 | log.Printf("[%s] error occurred: %+v\n", r.ID, err) 410 | } 411 | } 412 | 413 | for i := range files { 414 | if files[i].File != nil { 415 | log.Printf("[%s] removing file %s\n", r.ID, files[i].File.Name()) 416 | err := os.Remove(files[i].File.Name()) 417 | if err != nil { 418 | log.Printf("[%s] error removing file %s [%s]", r.ID, files[i].File.Name(), err) 419 | } 420 | } 421 | } 422 | 423 | log.Printf("[%s] finished handling %s\n", r.ID, h.ID) 424 | 425 | return string(out), err 426 | } 427 | 428 | func writeHttpResponseCode(w http.ResponseWriter, rid, hookId string, responseCode int) { 429 | // Check if the given return code is supported by the http package 430 | // by testing if there is a StatusText for this code. 431 | if len(http.StatusText(responseCode)) > 0 { 432 | w.WriteHeader(responseCode) 433 | } else { 434 | log.Printf("[%s] %s got matched, but the configured return code %d is unknown - defaulting to 200\n", rid, hookId, responseCode) 435 | } 436 | } 437 | -------------------------------------------------------------------------------- /internal/server/web.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net" 7 | "net/http" 8 | "strings" 9 | "time" 10 | 11 | chimiddleware "github.com/go-chi/chi/v5/middleware" 12 | "github.com/gorilla/mux" 13 | "github.com/soulteary/webhook/internal/flags" 14 | "github.com/soulteary/webhook/internal/link" 15 | "github.com/soulteary/webhook/internal/middleware" 16 | ) 17 | 18 | func Launch(appFlags flags.AppFlags, addr string, ln net.Listener) { 19 | r := mux.NewRouter() 20 | 21 | r.Use(middleware.RequestID( 22 | middleware.UseXRequestIDHeaderOption(appFlags.UseXRequestID), 23 | middleware.XRequestIDLimitOption(appFlags.XRequestIDLimit), 24 | )) 25 | r.Use(middleware.NewLogger()) 26 | r.Use(chimiddleware.Recoverer) 27 | 28 | if appFlags.Debug { 29 | r.Use(middleware.Dumper(log.Writer())) 30 | } 31 | 32 | // Clean up input 33 | appFlags.HttpMethods = strings.ToUpper(strings.ReplaceAll(appFlags.HttpMethods, " ", "")) 34 | 35 | hooksURL := link.MakeRoutePattern(&appFlags.HooksURLPrefix) 36 | 37 | r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { 38 | for _, responseHeader := range appFlags.ResponseHeaders { 39 | w.Header().Set(responseHeader.Name, responseHeader.Value) 40 | } 41 | 42 | fmt.Fprint(w, "OK") 43 | }) 44 | 45 | hookHandler := createHookHandler(appFlags) 46 | r.HandleFunc(hooksURL, hookHandler) 47 | 48 | // Create common HTTP server settings 49 | svr := &http.Server{ 50 | Addr: addr, 51 | Handler: r, 52 | ReadHeaderTimeout: 5 * time.Second, 53 | ReadTimeout: 5 * time.Second, 54 | } 55 | 56 | // Serve HTTP 57 | log.Printf("serving hooks on http://%s%s", addr, link.MakeHumanPattern(&appFlags.HooksURLPrefix)) 58 | log.Print(svr.Serve(ln)) 59 | } 60 | -------------------------------------------------------------------------------- /internal/server/webhook_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "os" 7 | "regexp" 8 | "runtime" 9 | "testing" 10 | 11 | "github.com/soulteary/webhook/internal/hook" 12 | ) 13 | 14 | func TestStaticParams(t *testing.T) { 15 | // FIXME(moorereason): incorporate this test into TestWebhook. 16 | // Need to be able to execute a binary with a space in the filename. 17 | if runtime.GOOS == "windows" { 18 | t.Skip("Skipping on Windows") 19 | } 20 | 21 | spHeaders := make(map[string]interface{}) 22 | spHeaders["User-Agent"] = "curl/7.54.0" 23 | spHeaders["Accept"] = "*/*" 24 | 25 | // case 2: binary with spaces in its name 26 | d1 := []byte("#!/bin/sh\n/bin/echo\n") 27 | err := os.WriteFile("/tmp/with space", d1, 0o755) 28 | if err != nil { 29 | t.Fatalf("%v", err) 30 | } 31 | defer os.Remove("/tmp/with space") 32 | 33 | spHook := &hook.Hook{ 34 | ID: "static-params-name-space", 35 | ExecuteCommand: "/tmp/with space", 36 | CommandWorkingDirectory: "/tmp", 37 | ResponseMessage: "success", 38 | CaptureCommandOutput: true, 39 | PassArgumentsToCommand: []hook.Argument{ 40 | {Source: "string", Name: "passed"}, 41 | }, 42 | } 43 | 44 | b := &bytes.Buffer{} 45 | log.SetOutput(b) 46 | 47 | r := &hook.Request{ 48 | ID: "test", 49 | Headers: spHeaders, 50 | } 51 | _, err = handleHook(spHook, r, nil) 52 | if err != nil { 53 | t.Fatalf("Unexpected error: %v\n", err) 54 | } 55 | matched, _ := regexp.MatchString("(?s)command output: .*static-params-name-space", b.String()) 56 | if !matched { 57 | t.Fatalf("Unexpected log output:\n%sn", b) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | // auto set by build system 4 | var ( 5 | Version = "dev" 6 | ) 7 | -------------------------------------------------------------------------------- /locales/en-US.toml: -------------------------------------------------------------------------------- 1 | WEBHOOK_VERSION = "webhook version " 2 | SERVER_IS_STARTING = "version %s starting" 3 | 4 | ERROR_SETUID_OR_SETGID = "error: setuid and setgid options must be used together" 5 | ERRPR_SERVER_LISTENING_PORT = "error listening on port: %s" 6 | ERRPR_SERVER_LISTENING_PRIVILEGES = "error dropping privileges: %s" 7 | ERROR_SERVER_OPENING_LOG_FILE = "error opening log file %q: %v" 8 | ERROR_CREATING_PID_FILE = "Error creating pidfile: %v" 9 | ERROR_COULD_NOT_LOAD_ANY_HOOKS = "couldn't load any hooks from file!\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to start without the hooks, either use -verbose flag, or -nopanic" 10 | -------------------------------------------------------------------------------- /locales/zh-CN.toml: -------------------------------------------------------------------------------- 1 | WEBHOOK_VERSION = "歪脖虎克(WebHook) 版本" 2 | SERVER_IS_STARTING = "版本 %s 正在启动" 3 | 4 | ERROR_SETUID_OR_SETGID = "错误: setuid 和 setgid 选项必须一起使用" 5 | ERROR_SERVER_LISTENING_PORT = "监听端口时发生错误: %s" 6 | ERROR_SERVER_LISTENING_PRIVILEGES = "设置权限时发生错误: %s" 7 | ERROR_SERVER_OPENING_LOG_FILE = "打开日志文件 %q 时发生错误: %v" 8 | ERROR_CREATING_PID_FILE = "创建 PID 文件时发生错误: %v" 9 | ERROR_COULD_NOT_LOAD_ANY_HOOKS = "无法从文件加载任何 hooks!\n你可以打开 -verbose 标志来查看详细信息\n如果您希望 Webhook 在没有 hooks 配置的情况下启动,可以使用 -verbose 标志或 -nopanic" 10 | -------------------------------------------------------------------------------- /test/hookecho.go: -------------------------------------------------------------------------------- 1 | // Hook Echo is a simply utility used for testing the Webhook package. 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | func main() { 13 | if len(os.Args) > 1 { 14 | fmt.Printf("arg: %s\n", strings.Join(os.Args[1:], " ")) 15 | } 16 | 17 | var env []string 18 | for _, v := range os.Environ() { 19 | if strings.HasPrefix(v, "HOOK_") { 20 | env = append(env, v) 21 | } 22 | } 23 | 24 | if len(env) > 0 { 25 | fmt.Printf("env: %s\n", strings.Join(env, " ")) 26 | } 27 | 28 | if (len(os.Args) > 1) && (strings.HasPrefix(os.Args[1], "exit=")) { 29 | exit_code_str := os.Args[1][5:] 30 | exit_code, err := strconv.Atoi(exit_code_str) 31 | if err != nil { 32 | fmt.Printf("Exit code %s not an int!", exit_code_str) 33 | os.Exit(-1) 34 | } 35 | os.Exit(exit_code) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/hooks.json.tmpl: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "github", 4 | "execute-command": "{{ .Hookecho }}", 5 | "command-working-directory": "/", 6 | "http-methods": ["Post "], 7 | "include-command-output-in-response": true, 8 | "trigger-rule-mismatch-http-response-code": 400, 9 | "pass-environment-to-command": 10 | [ 11 | { 12 | "source": "payload", 13 | "name": "head_commit.timestamp" 14 | } 15 | ], 16 | "pass-arguments-to-command": 17 | [ 18 | { 19 | "source": "payload", 20 | "name": "head_commit.id" 21 | }, 22 | { 23 | "source": "payload", 24 | "name": "head_commit.author.email" 25 | } 26 | ], 27 | "trigger-rule": 28 | { 29 | "and": 30 | [ 31 | { 32 | "match": 33 | { 34 | "type": "payload-hmac-sha1", 35 | "secret": "mysecret", 36 | "parameter": 37 | { 38 | "source": "header", 39 | "name": "X-Hub-Signature" 40 | } 41 | } 42 | }, 43 | { 44 | "match": 45 | { 46 | "type": "value", 47 | "value": "refs/heads/master", 48 | "parameter": 49 | { 50 | "source": "payload", 51 | "name": "ref" 52 | } 53 | } 54 | } 55 | ] 56 | } 57 | }, 58 | { 59 | "id": "github-multi-sig", 60 | "execute-command": "{{ .Hookecho }}", 61 | "command-working-directory": "/", 62 | "http-methods": ["Post "], 63 | "include-command-output-in-response": true, 64 | "trigger-rule-mismatch-http-response-code": 400, 65 | "trigger-signature-soft-failures": true, 66 | "pass-environment-to-command": 67 | [ 68 | { 69 | "source": "payload", 70 | "name": "head_commit.timestamp" 71 | } 72 | ], 73 | "pass-arguments-to-command": 74 | [ 75 | { 76 | "source": "payload", 77 | "name": "head_commit.id" 78 | }, 79 | { 80 | "source": "payload", 81 | "name": "head_commit.author.email" 82 | } 83 | ], 84 | "trigger-rule": 85 | { 86 | "and": 87 | [ 88 | "or": 89 | [ 90 | { 91 | "match": 92 | { 93 | "type": "payload-hmac-sha1", 94 | "secret": "mysecretFAIL", 95 | "parameter": 96 | { 97 | "source": "header", 98 | "name": "X-Hub-Signature" 99 | } 100 | } 101 | }, 102 | { 103 | "match": 104 | { 105 | "type": "payload-hmac-sha1", 106 | "secret": "mysecret", 107 | "parameter": 108 | { 109 | "source": "header", 110 | "name": "X-Hub-Signature" 111 | } 112 | } 113 | } 114 | ], 115 | { 116 | "match": 117 | { 118 | "type": "value", 119 | "value": "refs/heads/master", 120 | "parameter": 121 | { 122 | "source": "payload", 123 | "name": "ref" 124 | } 125 | } 126 | } 127 | ] 128 | } 129 | }, 130 | { 131 | "id": "github-multi-sig-fail", 132 | "execute-command": "{{ .Hookecho }}", 133 | "command-working-directory": "/", 134 | "http-methods": ["Post "], 135 | "include-command-output-in-response": true, 136 | "trigger-rule-mismatch-http-response-code": 400, 137 | "pass-environment-to-command": 138 | [ 139 | { 140 | "source": "payload", 141 | "name": "head_commit.timestamp" 142 | } 143 | ], 144 | "pass-arguments-to-command": 145 | [ 146 | { 147 | "source": "payload", 148 | "name": "head_commit.id" 149 | }, 150 | { 151 | "source": "payload", 152 | "name": "head_commit.author.email" 153 | } 154 | ], 155 | "trigger-rule": 156 | { 157 | "and": 158 | [ 159 | "or": 160 | [ 161 | { 162 | "match": 163 | { 164 | "type": "payload-hmac-sha1", 165 | "secret": "mysecretFAIL", 166 | "parameter": 167 | { 168 | "source": "header", 169 | "name": "X-Hub-Signature" 170 | } 171 | } 172 | }, 173 | { 174 | "match": 175 | { 176 | "type": "payload-hmac-sha1", 177 | "secret": "mysecret", 178 | "parameter": 179 | { 180 | "source": "header", 181 | "name": "X-Hub-Signature" 182 | } 183 | } 184 | } 185 | ], 186 | { 187 | "match": 188 | { 189 | "type": "value", 190 | "value": "refs/heads/master", 191 | "parameter": 192 | { 193 | "source": "payload", 194 | "name": "ref" 195 | } 196 | } 197 | } 198 | ] 199 | } 200 | }, 201 | { 202 | "id": "bitbucket", 203 | "execute-command": "{{ .Hookecho }}", 204 | "command-working-directory": "/", 205 | "include-command-output-in-response": false, 206 | "response-message": "success", 207 | "trigger-rule-mismatch-http-response-code": 999, 208 | "parse-parameters-as-json": [ 209 | { 210 | "source": "payload", 211 | "name": "payload" 212 | } 213 | ], 214 | "trigger-rule": { 215 | "and": [ 216 | { 217 | "match": { 218 | "type": "value", 219 | "parameter": { 220 | "source": "payload", 221 | "name": "payload.canon_url" 222 | }, 223 | "value": "https://bitbucket.org" 224 | } 225 | }, 226 | { 227 | "match": { 228 | "type": "value", 229 | "parameter": { 230 | "source": "payload", 231 | "name": "payload.repository.absolute_url" 232 | }, 233 | "value": "/webhook/testing/" 234 | } 235 | }, 236 | { 237 | "match": { 238 | "type": "value", 239 | "parameter": { 240 | "source": "payload", 241 | "name": "payload.commits.0.branch" 242 | }, 243 | "value": "master" 244 | } 245 | } 246 | ] 247 | } 248 | }, 249 | { 250 | "id": "gitlab", 251 | "execute-command": "{{ .Hookecho }}", 252 | "command-working-directory": "/", 253 | "response-message": "success", 254 | "include-command-output-in-response": true, 255 | "pass-arguments-to-command": 256 | [ 257 | { 258 | "source": "payload", 259 | "name": "commits.0.id" 260 | }, 261 | { 262 | "source": "payload", 263 | "name": "user_name" 264 | }, 265 | { 266 | "source": "payload", 267 | "name": "user_email" 268 | } 269 | ], 270 | "trigger-rule": 271 | { 272 | "match": 273 | { 274 | "type": "value", 275 | "value": "refs/heads/master", 276 | "parameter": 277 | { 278 | "source": "payload", 279 | "name": "ref" 280 | } 281 | } 282 | } 283 | }, 284 | { 285 | "id": "xml", 286 | "execute-command": "{{ .Hookecho }}", 287 | "command-working-directory": "/", 288 | "response-message": "success", 289 | "trigger-rule": { 290 | "and": [ 291 | { 292 | "match": { 293 | "type": "value", 294 | "parameter": { 295 | "source": "payload", 296 | "name": "app.users.user.0.-name" 297 | }, 298 | "value": "Jeff" 299 | } 300 | }, 301 | { 302 | "match": { 303 | "type": "value", 304 | "parameter": { 305 | "source": "payload", 306 | "name": "app.messages.message.#text" 307 | }, 308 | "value": "Hello!!" 309 | } 310 | }, 311 | ], 312 | } 313 | }, 314 | { 315 | "id": "txt-raw", 316 | "execute-command": "{{ .Hookecho }}", 317 | "command-working-directory": "/", 318 | "include-command-output-in-response": true, 319 | "pass-arguments-to-command": [ 320 | { 321 | "source": "raw-request-body" 322 | } 323 | ] 324 | }, 325 | { 326 | "id": "sendgrid", 327 | "execute-command": "{{ .Hookecho }}", 328 | "command-working-directory": "/", 329 | "response-message": "success", 330 | "trigger-rule": { 331 | "match": { 332 | "type": "value", 333 | "parameter": { 334 | "source": "payload", 335 | "name": "root.0.event" 336 | }, 337 | "value": "processed" 338 | } 339 | } 340 | }, 341 | { 342 | "id": "sendgrid/dir", 343 | "execute-command": "{{ .Hookecho }}", 344 | "command-working-directory": "/", 345 | "response-message": "success", 346 | "trigger-rule": { 347 | "match": { 348 | "type": "value", 349 | "parameter": { 350 | "source": "payload", 351 | "name": "root.0.event" 352 | }, 353 | "value": "it worked!" 354 | } 355 | } 356 | }, 357 | { 358 | "id": "plex", 359 | "execute-command": "{{ .Hookecho }}", 360 | "command-working-directory": "/", 361 | "response-message": "success", 362 | "parse-parameters-as-json": [ 363 | { 364 | "source": "payload", 365 | "name": "payload" 366 | } 367 | ], 368 | "trigger-rule": 369 | { 370 | "match": 371 | { 372 | "type": "value", 373 | "parameter": { 374 | "source": "payload", 375 | "name": "payload.event" 376 | }, 377 | "value": "media.play" 378 | } 379 | } 380 | }, 381 | { 382 | "id": "capture-command-output-on-success-not-by-default", 383 | "pass-arguments-to-command": [ 384 | { 385 | "source": "string", 386 | "name": "exit=0" 387 | } 388 | ], 389 | "execute-command": "{{ .Hookecho }}" 390 | }, 391 | { 392 | "id": "capture-command-output-on-success-yes-with-flag", 393 | "pass-arguments-to-command": [ 394 | { 395 | "source": "string", 396 | "name": "exit=0" 397 | } 398 | ], 399 | "execute-command": "{{ .Hookecho }}", 400 | "include-command-output-in-response": true 401 | }, 402 | { 403 | "id": "capture-command-output-on-error-not-by-default", 404 | "pass-arguments-to-command": [ 405 | { 406 | "source": "string", 407 | "name": "exit=1" 408 | } 409 | ], 410 | "execute-command": "{{ .Hookecho }}", 411 | "include-command-output-in-response": true 412 | }, 413 | { 414 | "id": "capture-command-output-on-error-yes-with-extra-flag", 415 | "pass-arguments-to-command": [ 416 | { 417 | "source": "string", 418 | "name": "exit=1" 419 | } 420 | ], 421 | "execute-command": "{{ .Hookecho }}", 422 | "include-command-output-in-response": true, 423 | "include-command-output-in-response-on-error": true 424 | }, 425 | { 426 | "id": "request-source", 427 | "pass-arguments-to-command": [ 428 | { 429 | "source": "request", 430 | "name": "method" 431 | }, 432 | { 433 | "source": "request", 434 | "name": "remote-addr" 435 | } 436 | ], 437 | "execute-command": "{{ .Hookecho }}", 438 | "include-command-output-in-response": true 439 | }, 440 | { 441 | "id": "static-params-ok", 442 | "execute-command": "{{ .Hookecho }}", 443 | "response-message": "success", 444 | "include-command-output-in-response": true, 445 | "pass-arguments-to-command": [ 446 | { 447 | "source": "string", 448 | "name": "passed" 449 | } 450 | ], 451 | }, 452 | { 453 | "id": "warn-on-space", 454 | "execute-command": "{{ .Hookecho }} foo", 455 | "response-message": "success", 456 | "include-command-output-in-response": true, 457 | "pass-arguments-to-command": [ 458 | { 459 | "source": "string", 460 | "name": "passed" 461 | } 462 | ], 463 | }, 464 | { 465 | "id": "issue-471", 466 | "execute-command": "{{ .Hookecho }}", 467 | "response-message": "success", 468 | "trigger-rule": 469 | { 470 | "or": 471 | [ 472 | { 473 | "match": 474 | { 475 | "parameter": 476 | { 477 | "source": "payload", 478 | "name": "foo" 479 | }, 480 | "type": "value", 481 | "value": "bar" 482 | } 483 | }, 484 | { 485 | "match": 486 | { 487 | "parameter": 488 | { 489 | "source": "payload", 490 | "name": "exists" 491 | }, 492 | "type": "value", 493 | "value": 1 494 | } 495 | } 496 | ] 497 | } 498 | }, 499 | { 500 | "id": "issue-471-and", 501 | "execute-command": "{{ .Hookecho }}", 502 | "response-message": "success", 503 | "trigger-rule": 504 | { 505 | "and": 506 | [ 507 | { 508 | "match": 509 | { 510 | "parameter": 511 | { 512 | "source": "payload", 513 | "name": "foo" 514 | }, 515 | "type": "value", 516 | "value": "bar" 517 | } 518 | }, 519 | { 520 | "match": 521 | { 522 | "parameter": 523 | { 524 | "source": "payload", 525 | "name": "exists" 526 | }, 527 | "type": "value", 528 | "value": 1 529 | } 530 | } 531 | ] 532 | } 533 | }, 534 | { 535 | "id": "empty-payload-signature", 536 | "execute-command": "{{ .Hookecho }}", 537 | "command-working-directory": "/", 538 | "include-command-output-in-response": true, 539 | "trigger-rule": 540 | { 541 | "and": 542 | [ 543 | { 544 | "match": 545 | { 546 | "type": "payload-hmac-sha1", 547 | "secret": "mysecret", 548 | "parameter": 549 | { 550 | "source": "header", 551 | "name": "X-Hub-Signature" 552 | } 553 | } 554 | } 555 | ] 556 | } 557 | } 558 | ] 559 | -------------------------------------------------------------------------------- /test/hooks.yaml.tmpl: -------------------------------------------------------------------------------- 1 | - id: github 2 | http-methods: 3 | - "Post " 4 | trigger-rule: 5 | and: 6 | - match: 7 | parameter: 8 | source: header 9 | name: X-Hub-Signature 10 | secret: mysecret 11 | type: payload-hmac-sha1 12 | - match: 13 | parameter: 14 | source: payload 15 | name: ref 16 | type: value 17 | value: refs/heads/master 18 | include-command-output-in-response: true 19 | trigger-rule-mismatch-http-response-code: 400 20 | execute-command: '{{ .Hookecho }}' 21 | pass-arguments-to-command: 22 | - source: payload 23 | name: head_commit.id 24 | - source: payload 25 | name: head_commit.author.email 26 | pass-environment-to-command: 27 | - source: payload 28 | name: head_commit.timestamp 29 | command-working-directory: / 30 | 31 | - id: github-multi-sig 32 | http-methods: 33 | - "Post " 34 | trigger-rule: 35 | and: 36 | - or: 37 | - match: 38 | parameter: 39 | source: header 40 | name: X-Hub-Signature 41 | secret: mysecretFAIL 42 | type: payload-hmac-sha1 43 | - match: 44 | parameter: 45 | source: header 46 | name: X-Hub-Signature 47 | secret: mysecret 48 | type: payload-hmac-sha1 49 | - match: 50 | parameter: 51 | source: payload 52 | name: ref 53 | type: value 54 | value: refs/heads/master 55 | include-command-output-in-response: true 56 | trigger-rule-mismatch-http-response-code: 400 57 | trigger-signature-soft-failures: true 58 | execute-command: '{{ .Hookecho }}' 59 | pass-arguments-to-command: 60 | - source: payload 61 | name: head_commit.id 62 | - source: payload 63 | name: head_commit.author.email 64 | pass-environment-to-command: 65 | - source: payload 66 | name: head_commit.timestamp 67 | command-working-directory: / 68 | 69 | - id: github-multi-sig-fail 70 | http-methods: 71 | - "Post " 72 | trigger-rule: 73 | and: 74 | - or: 75 | - match: 76 | parameter: 77 | source: header 78 | name: X-Hub-Signature 79 | secret: mysecretFAIL 80 | type: payload-hmac-sha1 81 | - match: 82 | parameter: 83 | source: header 84 | name: X-Hub-Signature 85 | secret: mysecret 86 | type: payload-hmac-sha1 87 | - match: 88 | parameter: 89 | source: payload 90 | name: ref 91 | type: value 92 | value: refs/heads/master 93 | include-command-output-in-response: true 94 | trigger-rule-mismatch-http-response-code: 400 95 | execute-command: '{{ .Hookecho }}' 96 | pass-arguments-to-command: 97 | - source: payload 98 | name: head_commit.id 99 | - source: payload 100 | name: head_commit.author.email 101 | pass-environment-to-command: 102 | - source: payload 103 | name: head_commit.timestamp 104 | command-working-directory: / 105 | 106 | - id: bitbucket 107 | trigger-rule: 108 | and: 109 | - match: 110 | parameter: 111 | source: payload 112 | name: payload.canon_url 113 | type: value 114 | value: https://bitbucket.org 115 | - match: 116 | parameter: 117 | source: payload 118 | name: payload.repository.absolute_url 119 | type: value 120 | value: /webhook/testing/ 121 | - match: 122 | parameter: 123 | source: payload 124 | name: payload.commits.0.branch 125 | type: value 126 | value: master 127 | parse-parameters-as-json: 128 | - source: payload 129 | name: payload 130 | trigger-rule-mismatch-http-response-code: 999 131 | execute-command: '{{ .Hookecho }}' 132 | response-message: success 133 | include-command-output-in-response: false 134 | command-working-directory: / 135 | 136 | - id: gitlab 137 | trigger-rule: 138 | match: 139 | parameter: 140 | source: payload 141 | name: ref 142 | type: value 143 | value: refs/heads/master 144 | pass-arguments-to-command: 145 | - source: payload 146 | name: commits.0.id 147 | - source: payload 148 | name: user_name 149 | - source: payload 150 | name: user_email 151 | execute-command: '{{ .Hookecho }}' 152 | response-message: success 153 | include-command-output-in-response: true 154 | command-working-directory: / 155 | 156 | - id: xml 157 | execute-command: '{{ .Hookecho }}' 158 | command-working-directory: / 159 | response-message: success 160 | trigger-rule: 161 | and: 162 | - match: 163 | type: value 164 | parameter: 165 | source: payload 166 | name: app.users.user.0.-name 167 | value: Jeff 168 | - match: 169 | type: value 170 | parameter: 171 | source: payload 172 | name: "app.messages.message.#text" 173 | value: "Hello!!" 174 | 175 | - id: txt-raw 176 | execute-command: '{{ .Hookecho }}' 177 | command-working-directory: / 178 | include-command-output-in-response: true 179 | pass-arguments-to-command: 180 | - source: raw-request-body 181 | 182 | - id: sendgrid 183 | execute-command: '{{ .Hookecho }}' 184 | command-working-directory: / 185 | response-message: success 186 | trigger-rule: 187 | match: 188 | type: value 189 | parameter: 190 | source: payload 191 | name: root.0.event 192 | value: processed 193 | 194 | - id: sendgrid/dir 195 | execute-command: '{{ .Hookecho }}' 196 | command-working-directory: / 197 | response-message: success 198 | trigger-rule: 199 | match: 200 | type: value 201 | parameter: 202 | source: payload 203 | name: root.0.event 204 | value: it worked! 205 | 206 | - id: plex 207 | trigger-rule: 208 | match: 209 | type: value 210 | parameter: 211 | source: payload 212 | name: payload.event 213 | value: media.play 214 | parse-parameters-as-json: 215 | - source: payload 216 | name: payload 217 | execute-command: '{{ .Hookecho }}' 218 | response-message: success 219 | command-working-directory: / 220 | 221 | - id: capture-command-output-on-success-not-by-default 222 | pass-arguments-to-command: 223 | - source: string 224 | name: exit=0 225 | execute-command: '{{ .Hookecho }}' 226 | 227 | - id: capture-command-output-on-success-yes-with-flag 228 | pass-arguments-to-command: 229 | - source: string 230 | name: exit=0 231 | execute-command: '{{ .Hookecho }}' 232 | include-command-output-in-response: true 233 | 234 | - id: capture-command-output-on-error-not-by-default 235 | pass-arguments-to-command: 236 | - source: string 237 | name: exit=1 238 | execute-command: '{{ .Hookecho }}' 239 | include-command-output-in-response: true 240 | 241 | - id: capture-command-output-on-error-yes-with-extra-flag 242 | pass-arguments-to-command: 243 | - source: string 244 | name: exit=1 245 | execute-command: '{{ .Hookecho }}' 246 | include-command-output-in-response: true 247 | include-command-output-in-response-on-error: true 248 | 249 | - id: request-source 250 | pass-arguments-to-command: 251 | - source: request 252 | name: method 253 | - source: request 254 | name: remote-addr 255 | execute-command: '{{ .Hookecho }}' 256 | include-command-output-in-response: true 257 | 258 | - id: static-params-ok 259 | execute-command: '{{ .Hookecho }}' 260 | include-command-output-in-response: true 261 | pass-arguments-to-command: 262 | - source: string 263 | name: passed 264 | 265 | - id: warn-on-space 266 | execute-command: '{{ .Hookecho }} foo' 267 | include-command-output-in-response: true 268 | 269 | - id: issue-471 270 | execute-command: '{{ .Hookecho }}' 271 | response-message: success 272 | trigger-rule: 273 | or: 274 | - match: 275 | parameter: 276 | source: payload 277 | name: foo 278 | type: value 279 | value: bar 280 | - match: 281 | parameter: 282 | source: payload 283 | name: exists 284 | type: value 285 | value: 1 286 | 287 | - id: issue-471-and 288 | execute-command: '{{ .Hookecho }}' 289 | response-message: success 290 | trigger-rule: 291 | and: 292 | - match: 293 | parameter: 294 | source: payload 295 | name: foo 296 | type: value 297 | value: bar 298 | - match: 299 | parameter: 300 | source: payload 301 | name: exists 302 | type: value 303 | value: 1 304 | 305 | - id: empty-payload-signature 306 | include-command-output-in-response: true 307 | execute-command: '{{ .Hookecho }}' 308 | command-working-directory: / 309 | trigger-rule: 310 | and: 311 | - match: 312 | parameter: 313 | source: header 314 | name: X-Hub-Signature 315 | secret: mysecret 316 | type: payload-hmac-sha1 317 | -------------------------------------------------------------------------------- /webhook.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net" 9 | "os" 10 | 11 | "github.com/soulteary/webhook/internal/flags" 12 | "github.com/soulteary/webhook/internal/i18n" 13 | "github.com/soulteary/webhook/internal/monitor" 14 | "github.com/soulteary/webhook/internal/pidfile" 15 | "github.com/soulteary/webhook/internal/platform" 16 | "github.com/soulteary/webhook/internal/rules" 17 | "github.com/soulteary/webhook/internal/server" 18 | "github.com/soulteary/webhook/internal/version" 19 | ) 20 | 21 | var ( 22 | signals chan os.Signal 23 | pidFile *pidfile.PIDFile 24 | ) 25 | 26 | //go:embed locales/*.toml 27 | var WebhookLocales embed.FS 28 | 29 | func NeedEchoVersionInfo(appFlags flags.AppFlags) { 30 | if appFlags.ShowVersion { 31 | i18n.Println(i18n.MSG_WEBHOOK_VERSION, version.Version) 32 | os.Exit(0) 33 | } 34 | } 35 | 36 | func CheckPrivilegesParamsCorrect(appFlags flags.AppFlags) { 37 | if (appFlags.SetUID != 0 || appFlags.SetGID != 0) && (appFlags.SetUID == 0 || appFlags.SetGID == 0) { 38 | i18n.Println(i18n.MSG_SETUID_OR_SETGID_ERROR) 39 | os.Exit(1) 40 | } 41 | } 42 | 43 | func GetNetAddr(appFlags flags.AppFlags, logQueue *[]string) (string, *net.Listener) { 44 | addr := fmt.Sprintf("%s:%d", appFlags.Host, appFlags.Port) 45 | // Open listener early so we can drop privileges. 46 | ln, err := net.Listen("tcp", addr) 47 | if err != nil { 48 | *logQueue = append(*logQueue, i18n.Sprintf(i18n.ERR_SERVER_LISTENING_PORT, err)) 49 | } 50 | return addr, &ln 51 | } 52 | 53 | func DropPrivileges(appFlags flags.AppFlags, logQueue *[]string) { 54 | if appFlags.SetUID != 0 { 55 | err := platform.DropPrivileges(appFlags.SetUID, appFlags.SetGID) 56 | if err != nil { 57 | *logQueue = append(*logQueue, i18n.Sprintf(i18n.ERR_SERVER_LISTENING_PRIVILEGES, err)) 58 | } 59 | } 60 | } 61 | 62 | func SetupLogger(appFlags flags.AppFlags, logQueue *[]string) (logFile *os.File, err error) { 63 | if appFlags.LogPath != "" { 64 | logFile, err = os.OpenFile(appFlags.LogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) 65 | if err != nil { 66 | *logQueue = append(*logQueue, i18n.Sprintf(i18n.ERR_SERVER_OPENING_LOG_FILE, appFlags.LogPath, err)) 67 | } 68 | } 69 | return logFile, err 70 | } 71 | 72 | func main() { 73 | appFlags := flags.Parse() 74 | 75 | i18n.GLOBAL_LOCALES = i18n.InitLocaleByFiles(i18n.LoadLocaleFiles(appFlags.I18nDir, WebhookLocales)) 76 | i18n.GLOBAL_LANG = appFlags.Lang 77 | 78 | // check if we need to echo version info and quit app 79 | NeedEchoVersionInfo(appFlags) 80 | // check if the privileges params are correct, or exit(1) 81 | CheckPrivilegesParamsCorrect(appFlags) 82 | 83 | if appFlags.Debug || appFlags.LogPath != "" { 84 | appFlags.Verbose = true 85 | } 86 | 87 | if len(rules.HooksFiles) == 0 { 88 | rules.HooksFiles = append(rules.HooksFiles, "hooks.json") 89 | } 90 | 91 | // logQueue is a queue for log messages encountered during startup. We need 92 | // to queue the messages so that we can handle any privilege dropping and 93 | // log file opening prior to writing our first log message. 94 | var logQueue []string 95 | 96 | // set up net listener and get listening address 97 | addr, ln := GetNetAddr(appFlags, &logQueue) 98 | // drop privileges 99 | DropPrivileges(appFlags, &logQueue) 100 | // setup logger 101 | logFile, err := SetupLogger(appFlags, &logQueue) 102 | if err == nil && logFile != nil { 103 | log.SetOutput(logFile) 104 | } 105 | log.SetPrefix("[webhook] ") 106 | log.SetFlags(log.Ldate | log.Ltime) 107 | 108 | if len(logQueue) != 0 { 109 | for i := range logQueue { 110 | log.Println(logQueue[i]) 111 | } 112 | os.Exit(1) 113 | } 114 | 115 | if !appFlags.Verbose { 116 | log.SetOutput(io.Discard) 117 | } 118 | 119 | // Create pidfile 120 | if appFlags.PidPath != "" { 121 | var err error 122 | 123 | pidFile, err = pidfile.New(appFlags.PidPath) 124 | if err != nil { 125 | log.Fatal(i18n.ERR_CREATING_PID_FILE, err) 126 | } 127 | 128 | defer func() { 129 | // NOTE(moorereason): my testing shows that this doesn't work with 130 | // ^C, so we also do a Remove in the signal handler elsewhere. 131 | if nerr := pidFile.Remove(); nerr != nil { 132 | log.Print(nerr) 133 | } 134 | }() 135 | } 136 | 137 | log.Println(i18n.Sprintf(i18n.MSG_SERVER_IS_STARTING, version.Version)) 138 | 139 | // set os signal watcher 140 | if appFlags.AsTemplate { 141 | platform.SetupSignals(signals, rules.ReloadAllHooksAsTemplate, pidFile) 142 | } else { 143 | platform.SetupSignals(signals, rules.ReloadAllHooksNotAsTemplate, pidFile) 144 | } 145 | 146 | // load and parse hooks 147 | rules.ParseAndLoadHooks(appFlags.AsTemplate) 148 | 149 | if !appFlags.Verbose && !appFlags.NoPanic && rules.LenLoadedHooks() == 0 { 150 | log.SetOutput(os.Stdout) 151 | log.Fatalln(i18n.Sprintf(i18n.ERR_COULD_NOT_LOAD_ANY_HOOKS)) 152 | } 153 | 154 | if appFlags.HotReload { 155 | monitor.ApplyWatcher(appFlags) 156 | } 157 | 158 | server.Launch(appFlags, addr, *ln) 159 | } 160 | --------------------------------------------------------------------------------