├── .dockerignore ├── .github ├── dependabot.yml ├── dockerhub.png ├── release.png └── workflows │ ├── build.yml │ ├── codeql.yml │ └── scan.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── docker ├── goreleaser │ ├── Dockerfile │ └── Dockerfile.extend └── manual │ ├── Dockerfile │ └── Dockerfile.alpine ├── docs ├── 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 ├── example ├── hooks.json ├── hooks.json.tmpl ├── hooks.yaml └── hooks.yaml.tmpl ├── go.mod ├── go.sum ├── internal ├── flags │ ├── define.go │ ├── flags.go │ └── fn.go ├── hook │ ├── hook.go │ ├── hook_new_test.go │ ├── hook_test.go │ ├── request.go │ ├── request_test.go │ └── testdata │ │ └── unrecognized.yaml ├── link │ ├── link.go │ └── link_test.go ├── middleware │ ├── dumper.go │ ├── logger.go │ └── request_id.go ├── monitor │ └── monitor.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 │ ├── rules.go │ └── rules_test.go └── version │ └── version.go ├── 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-classic/b3210fb5628f48660445a12ebf838541adb2233a/.github/dockerhub.png -------------------------------------------------------------------------------- /.github/release.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soulteary/webhook-classic/b3210fb5628f48660445a12ebf838541adb2233a/.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.22" 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.22" 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 | 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v3 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v3 63 | with: 64 | category: "/language:${{matrix.language}}" 65 | -------------------------------------------------------------------------------- /.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 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Check out code into the Go module directory 24 | uses: actions/checkout@v4 25 | 26 | - name: Security Scan 27 | uses: securego/gosec@master 28 | with: 29 | args: "-no-fail -fmt sarif -out results.sarif ./..." 30 | 31 | - name: Upload SARIF file 32 | uses: github/codeql-action/upload-sarif@v2 33 | with: 34 | sarif_file: results.sarif 35 | -------------------------------------------------------------------------------- /.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.Version={{ .Tag }}" 9 | id: macos 10 | goos: [darwin] 11 | goarch: [amd64, arm64] 12 | main: ./bin 13 | 14 | - <<: *build_defaults 15 | id: linux 16 | goos: [linux] 17 | goarch: ["386", arm, amd64, arm64] 18 | goarm: 19 | - "7" 20 | - "6" 21 | 22 | dockers: 23 | - image_templates: 24 | - "soulteary/webhook:linux-amd64-{{ .Tag }}" 25 | - "soulteary/webhook:linux-amd64" 26 | dockerfile: docker/goreleaser/Dockerfile 27 | use: buildx 28 | goarch: amd64 29 | build_flag_templates: 30 | - "--pull" 31 | - "--platform=linux/amd64" 32 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 33 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 34 | - "--label=org.opencontainers.image.url=https://github.com/soulteary/webhook" 35 | - "--label=org.opencontainers.image.source=https://github.com/soulteary/webhook" 36 | - "--label=org.opencontainers.image.version={{ .Version }}" 37 | - "--label=org.opencontainers.image.created={{ .Date }}" 38 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 39 | - "--label=org.opencontainers.image.licenses=MIT" 40 | 41 | - image_templates: 42 | - "soulteary/webhook:linux-arm64-{{ .Tag }}" 43 | - "soulteary/webhook:linux-arm64" 44 | dockerfile: docker/goreleaser/Dockerfile 45 | use: buildx 46 | goos: linux 47 | goarch: arm64 48 | goarm: "" 49 | build_flag_templates: 50 | - "--pull" 51 | - "--platform=linux/arm64" 52 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 53 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 54 | - "--label=org.opencontainers.image.url=https://github.com/soulteary/webhook" 55 | - "--label=org.opencontainers.image.source=https://github.com/soulteary/webhook" 56 | - "--label=org.opencontainers.image.version={{ .Version }}" 57 | - "--label=org.opencontainers.image.created={{ .Date }}" 58 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 59 | - "--label=org.opencontainers.image.licenses=MIT" 60 | 61 | - image_templates: 62 | - "soulteary/webhook:linux-armv7-{{ .Tag }}" 63 | - "soulteary/webhook:linux-armv7" 64 | dockerfile: docker/goreleaser/Dockerfile 65 | use: buildx 66 | goos: linux 67 | goarch: arm 68 | goarm: "7" 69 | build_flag_templates: 70 | - "--pull" 71 | - "--platform=linux/arm/v7" 72 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 73 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 74 | - "--label=org.opencontainers.image.url=https://github.com/soulteary/webhook" 75 | - "--label=org.opencontainers.image.source=https://github.com/soulteary/webhook" 76 | - "--label=org.opencontainers.image.version={{ .Version }}" 77 | - "--label=org.opencontainers.image.created={{ .Date }}" 78 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 79 | - "--label=org.opencontainers.image.licenses=MIT" 80 | 81 | - image_templates: 82 | - "soulteary/webhook:linux-armv6-{{ .Tag }}" 83 | - "soulteary/webhook:linux-armv6" 84 | dockerfile: docker/goreleaser/Dockerfile 85 | use: buildx 86 | goos: linux 87 | goarch: arm 88 | goarm: "6" 89 | build_flag_templates: 90 | - "--pull" 91 | - "--platform=linux/arm/v6" 92 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 93 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 94 | - "--label=org.opencontainers.image.url=https://github.com/soulteary/webhook" 95 | - "--label=org.opencontainers.image.source=https://github.com/soulteary/webhook" 96 | - "--label=org.opencontainers.image.version={{ .Version }}" 97 | - "--label=org.opencontainers.image.created={{ .Date }}" 98 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 99 | - "--label=org.opencontainers.image.licenses=MIT" 100 | 101 | - image_templates: 102 | - "soulteary/webhook:linux-amd64-extend-{{ .Tag }}" 103 | - "soulteary/webhook:linux-amd64-extend" 104 | dockerfile: docker/goreleaser/Dockerfile.extend 105 | use: buildx 106 | goarch: amd64 107 | build_flag_templates: 108 | - "--pull" 109 | - "--platform=linux/amd64" 110 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 111 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 112 | - "--label=org.opencontainers.image.url=https://github.com/soulteary/webhook" 113 | - "--label=org.opencontainers.image.source=https://github.com/soulteary/webhook" 114 | - "--label=org.opencontainers.image.version={{ .Version }}" 115 | - "--label=org.opencontainers.image.created={{ .Date }}" 116 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 117 | - "--label=org.opencontainers.image.licenses=MIT" 118 | 119 | - image_templates: 120 | - "soulteary/webhook:linux-arm64-extend-{{ .Tag }}" 121 | - "soulteary/webhook:linux-arm64-extend" 122 | dockerfile: docker/goreleaser/Dockerfile.extend 123 | use: buildx 124 | goos: linux 125 | goarch: arm64 126 | goarm: "" 127 | build_flag_templates: 128 | - "--pull" 129 | - "--platform=linux/arm64" 130 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 131 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 132 | - "--label=org.opencontainers.image.url=https://github.com/soulteary/webhook" 133 | - "--label=org.opencontainers.image.source=https://github.com/soulteary/webhook" 134 | - "--label=org.opencontainers.image.version={{ .Version }}" 135 | - "--label=org.opencontainers.image.created={{ .Date }}" 136 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 137 | - "--label=org.opencontainers.image.licenses=MIT" 138 | 139 | - image_templates: 140 | - "soulteary/webhook:linux-armv7-extend-{{ .Tag }}" 141 | - "soulteary/webhook:linux-armv7-extend" 142 | dockerfile: docker/goreleaser/Dockerfile.extend 143 | use: buildx 144 | goos: linux 145 | goarch: arm 146 | goarm: "7" 147 | build_flag_templates: 148 | - "--pull" 149 | - "--platform=linux/arm/v7" 150 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 151 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 152 | - "--label=org.opencontainers.image.url=https://github.com/soulteary/webhook" 153 | - "--label=org.opencontainers.image.source=https://github.com/soulteary/webhook" 154 | - "--label=org.opencontainers.image.version={{ .Version }}" 155 | - "--label=org.opencontainers.image.created={{ .Date }}" 156 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 157 | - "--label=org.opencontainers.image.licenses=MIT" 158 | 159 | - image_templates: 160 | - "soulteary/webhook:linux-armv6-extend-{{ .Tag }}" 161 | - "soulteary/webhook:linux-armv6-extend" 162 | dockerfile: docker/goreleaser/Dockerfile.extend 163 | use: buildx 164 | goos: linux 165 | goarch: arm 166 | goarm: "6" 167 | build_flag_templates: 168 | - "--pull" 169 | - "--platform=linux/arm/v6" 170 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 171 | - "--label=org.opencontainers.image.description={{ .ProjectName }}" 172 | - "--label=org.opencontainers.image.url=https://github.com/soulteary/webhook" 173 | - "--label=org.opencontainers.image.source=https://github.com/soulteary/webhook" 174 | - "--label=org.opencontainers.image.version={{ .Version }}" 175 | - "--label=org.opencontainers.image.created={{ .Date }}" 176 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 177 | - "--label=org.opencontainers.image.licenses=MIT" 178 | 179 | docker_manifests: 180 | - name_template: "soulteary/webhook:{{ .Tag }}" 181 | image_templates: 182 | - "soulteary/webhook:linux-amd64-{{ .Tag }}" 183 | - "soulteary/webhook:linux-arm64-{{ .Tag }}" 184 | - "soulteary/webhook:linux-armv7-{{ .Tag }}" 185 | - "soulteary/webhook:linux-armv6-{{ .Tag }}" 186 | skip_push: "false" 187 | 188 | - name_template: "soulteary/webhook:extend-{{ .Tag }}" 189 | image_templates: 190 | - "soulteary/webhook:linux-amd64-extend-{{ .Tag }}" 191 | - "soulteary/webhook:linux-arm64-extend-{{ .Tag }}" 192 | - "soulteary/webhook:linux-armv7-extend-{{ .Tag }}" 193 | - "soulteary/webhook:linux-armv6-extend-{{ .Tag }}" 194 | skip_push: "false" 195 | 196 | - name_template: "soulteary/webhook:latest" 197 | image_templates: 198 | - "soulteary/webhook:linux-amd64-{{ .Tag }}" 199 | - "soulteary/webhook:linux-arm64-{{ .Tag }}" 200 | - "soulteary/webhook:linux-armv7-{{ .Tag }}" 201 | - "soulteary/webhook:linux-armv6-{{ .Tag }}" 202 | skip_push: "false" 203 | -------------------------------------------------------------------------------- /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.md: -------------------------------------------------------------------------------- 1 | # 新的项目地址 2 | 3 | - **项目迁移至 [https://github.com/soulteary/webhook](https://github.com/soulteary/webhook), 更健壮,更稳定,更纯净。** 4 | - **move to https://github.com/soulteary/webhook , more robust, more stable and clean.** 5 | 6 | ----- 7 | 8 | # 什么是 WebHook (歪脖虎克)? 9 | 10 | ![build-status][badge] [![Go Report Card](https://goreportcard.com/badge/github.com/soulteary/webhook)](https://goreportcard.com/report/github.com/soulteary/webhook) 11 | 12 | Webhook 13 | 14 | [webhook][w] 是一个用 Go 语言编写的轻量可配置实用工具,它允许你在服务器上轻松创建 HTTP 服务(钩子),你可以使用这些服务来执行配置好的命令。你还可以将 HTTP 请求中的数据(如请求头内容、请求体以及请求参数)传递给你配置好的命令。[webhook][w] 还允许根据具体的条件规则来便触发钩子。 15 | 16 | 例如,如果你使用的是 Github 或 Gitea,可以使用 [webhook][w] 设置一个钩子,在每次你推送更改到项目的某个分支时,这个钩子会在你的暂存服务器上运行一个重新部署脚本。 17 | 18 | 如果你使用 飞书、Mattermost 或 Slack,你可以设置一个“传出 Webhook 集成”或“斜杠命令”,来在你的服务器上运行各种命令,然后可以通过“传入 Webhook 集成”或处理合适的响应体,直接向你或你的 IM 会话或频道报告执行结果。 19 | 20 | [webhook][w] 的目标只做它应该做的事情,那就是: 21 | 22 | 1. 接收请求, 23 | 2. 解析请求头、请求体和请求参数, 24 | 3. 检查钩子指定的运行规则是否得到满足, 25 | 4. 最后,通过命令行参数或环境变量将指定的参数传递给指定的命令。 26 | 27 | 其他的所有事情,都需要命令作者的来完成。 28 | 29 | # 入门 30 | 31 | ## 软件安装 32 | 33 | ### Docker 34 | 35 | ![](.github/dockerhub.png) 36 | 37 | 你可以使用下面的任一命令来下载本仓库自动构建的可执行程序镜像: 38 | 39 | ```bash 40 | docker pull soulteary/webhook:latest 41 | docker pull soulteary/webhook:3.2.0 42 | ``` 43 | 44 | 如果你希望镜像中有一些方便调试的工具,可以使用下面的命令,获取扩展版的镜像: 45 | 46 | ```bash 47 | docker pull soulteary/webhook:extend-3.2.0 48 | ``` 49 | 50 | 然后我们可以基于这个镜像来构建和完善我们命令所需要的运行环境。 51 | 52 | **参考教程【TBD】** 53 | 54 | ### 下载预构建程序 55 | 56 | [![](.github/release.png)](https://github.com/soulteary/webhook/releases) 57 | 58 | 不同架构的预编译二进制文件可在 [GitHub 发布](https://github.com/soulteary/webhook/releases) 页面获取。 59 | 60 | ## 配置 61 | 62 | 我们可以来定义一些你希望 [webhook][w] 提供 HTTP 服务使用的钩子。 63 | 64 | [webhook][w] 支持 JSON 或 YAML 配置文件,我们先来看看如何实现 JSON 配置。 65 | 66 | 首先,创建一个名为 hooks.json 的空文件。这个文件将包含 [webhook][w] 将要启动为 HTTP 服务的钩子的数组。查看 [Hook definition page](docs/Hook-Definition.md),可以查看钩子可以包含哪些属性,以及如何使用它们的详细描述。 67 | 68 | 让我们定义一个简单的名为 redeploy-webhook 的钩子,它将运行位于 `/var/scripts/redeploy.sh` 的重新部署脚本。确保你的 bash 脚本在顶部有 `#!/bin/sh`。 69 | 70 | 我们的 hooks.json 文件将如下所示: 71 | 72 | ```json 73 | [ 74 | { 75 | "id": "redeploy-webhook", 76 | "execute-command": "/var/scripts/redeploy.sh", 77 | "command-working-directory": "/var/webhook" 78 | } 79 | ] 80 | ``` 81 | 82 | 如果你更喜欢使用 YAML,相应的 hooks.yaml 文件内容为: 83 | 84 | ```yaml 85 | - id: redeploy-webhook 86 | execute-command: "/var/scripts/redeploy.sh" 87 | command-working-directory: "/var/webhook" 88 | ``` 89 | 90 | 接下来,你可以通过下面的命令来执行 [webhook][w]: 91 | 92 | ```bash 93 | $ /path/to/webhook -hooks hooks.json -verbose 94 | ``` 95 | 96 | 程序将在默认的 9000 端口启动,并提供一个公开可访问的 HTTP 服务地址: 97 | 98 | ```http 99 | http://yourserver:9000/hooks/redeploy-webhook 100 | ``` 101 | 102 | 查看 [webhook 参数](docs/Webhook-Parameters.md) 了解如何在启动 [webhook][w] 时设置 IP、端口以及其它设置,例如钩子的热重载,详细输出等。 103 | 104 | 当有任何 HTTP GET 或 POST 请求访问到服务地址后,你设置的重新部署脚本将被执行。 105 | 106 | 不过,像这样定义的钩子可能会对你的系统构成安全威胁,因为任何知道你端点的人都可以发送请求并执行命令。为了防止这种情况,你可以使用钩子的 "trigger-rule" 属性来指定触发钩子的确切条件。例如,你可以使用它们添加一个秘密参数,必须提供这个参数才能成功触发钩子。请查看 [Hook 规则](docs/Hook-Rules.md) 以获取可用规则及其使用方法的详细列表。 107 | 108 | ## 表单数据 109 | 110 | [webhook][w] 提供了对表单数据的有限解析支持。 111 | 112 | 表单数据通常可以包含两种类型的部分:值和文件。 113 | 所有表单 _值_ 会自动添加到 `payload` 范围内。 114 | 使用 `parse-parameters-as-json` 设置将给定值解析为 JSON。 115 | 除非符合以下标准之一,否则所有文件都会被忽略: 116 | 117 | 1. `Content-Type` 标头是 `application/json`。 118 | 2. 部分在 `parse-parameters-as-json` 设置中被命名。 119 | 120 | 在任一情况下,给定的文件部分将被解析为 JSON 并添加到 payload 映射中。 121 | 122 | ## 模版 123 | 124 | 当使用 `-template` [命令行参数](docs/Webhook-Parameters.md)时,[webhook][w] 可以将钩子配置文件解析为 Go 模板。有关模板使用的更多详情,请查看[模版](docs/Templates.md)。 125 | 126 | ## 使用 HTTPS 127 | 128 | [webhook][w] 默认使用 http 提供服务。如果你希望 [webhook][w] 使用 https 提供 HTTPS 服务,更简单的方案是使用反向代理或者使用 traefik 等服务来提供 HTTPS 服务。 129 | 130 | ## 跨域 CORS 请求头 131 | 132 | 如果你想设置 CORS 头,可以在启动 [webhook][w] 时使用 `-header name=value` 标志来设置将随每个响应返回的适当 CORS 头。 133 | 134 | ## 使用示例 135 | 136 | 查看 [Hook 示例](docs/Hook-Examples.md) 来学习各种新鲜的使用。 137 | 138 | # 为什么要作一个开源软件的分叉 139 | 140 | 主要有两个原因: 141 | 142 | 1. 作者维护的版本是从比较陈旧的版本升级上来的,包含了许多不再被需要的内容,我在几年前曾经提交过一个[改进版本的 PR](https://github.com/soulteary/webhook/pull/570),但是因为种种原因被作者忽略,**与其继续使用明知道不可靠的程序,不如将它变的可靠。** 143 | 2. 除了更容易从社区合并未被原始仓库作者合并的社区功能外,还可以快速对有安全风险的依赖作更新,以及我希望这个程序接下来能够中文更加友好,包括文档。 144 | 145 | [w]: https://github.com/soulteary/webhook 146 | [badge]: https://github.com/soulteary/webhook/workflows/build/badge.svg 147 | -------------------------------------------------------------------------------- /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 8345/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 8345/tcp 10 | CMD ["/usr/bin/webhook"] 11 | -------------------------------------------------------------------------------- /docker/manual/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22-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 ./bin 8 | 9 | FROM debian:stretch 10 | LABEL maintainer "soulteary " 11 | COPY --from=builder /app/webhook /bin/ 12 | CMD webhook 13 | -------------------------------------------------------------------------------- /docker/manual/Dockerfile.alpine: -------------------------------------------------------------------------------- 1 | FROM golang:1.22-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 ./bin 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 8345/tcp 17 | CMD webhook 18 | -------------------------------------------------------------------------------- /docs/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/Hook-Examples.md: -------------------------------------------------------------------------------- 1 | # Hook Examples 2 | 3 | Hooks are defined in a hooks configuration file in either JSON or YAML format, 4 | although the examples on this page all use the JSON format. 5 | 6 | 🌱 This page is still a work in progress. Feel free to contribute! 7 | 8 | ### Table of Contents 9 | 10 | * [Incoming Github webhook](#incoming-github-webhook) 11 | * [Incoming Bitbucket webhook](#incoming-bitbucket-webhook) 12 | * [Incoming Gitlab webhook](#incoming-gitlab-webhook) 13 | * [Incoming Gogs webhook](#incoming-gogs-webhook) 14 | * [Incoming Gitea webhook](#incoming-gitea-webhook) 15 | * [Slack slash command](#slack-slash-command) 16 | * [A simple webhook with a secret key in GET query](#a-simple-webhook-with-a-secret-key-in-get-query) 17 | * [JIRA Webhooks](#jira-webhooks) 18 | * [Pass File-to-command sample](#pass-file-to-command-sample) 19 | * [Incoming Scalr Webhook](#incoming-scalr-webhook) 20 | * [Travis CI webhook](#travis-ci-webhook) 21 | * [XML Payload](#xml-payload) 22 | * [Multipart Form Data](#multipart-form-data) 23 | * [Pass string arguments to command](#pass-string-arguments-to-command) 24 | * [Receive Synology DSM notifications](#receive-synology-notifications) 25 | 26 | ## Incoming Github webhook 27 | 28 | This example works on 2.8+ versions of Webhook - if you are on a previous series, change `payload-hmac-sha1` to `payload-hash-sha1`. 29 | 30 | ```json 31 | [ 32 | { 33 | "id": "webhook", 34 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 35 | "command-working-directory": "/home/adnan/go", 36 | "pass-arguments-to-command": 37 | [ 38 | { 39 | "source": "payload", 40 | "name": "head_commit.id" 41 | }, 42 | { 43 | "source": "payload", 44 | "name": "pusher.name" 45 | }, 46 | { 47 | "source": "payload", 48 | "name": "pusher.email" 49 | } 50 | ], 51 | "trigger-rule": 52 | { 53 | "and": 54 | [ 55 | { 56 | "match": 57 | { 58 | "type": "payload-hmac-sha1", 59 | "secret": "mysecret", 60 | "parameter": 61 | { 62 | "source": "header", 63 | "name": "X-Hub-Signature" 64 | } 65 | } 66 | }, 67 | { 68 | "match": 69 | { 70 | "type": "value", 71 | "value": "refs/heads/master", 72 | "parameter": 73 | { 74 | "source": "payload", 75 | "name": "ref" 76 | } 77 | } 78 | } 79 | ] 80 | } 81 | } 82 | ] 83 | ``` 84 | 85 | ## Incoming Bitbucket webhook 86 | 87 | Bitbucket does not pass any secrets back to the webhook. [Per their documentation](https://support.atlassian.com/organization-administration/docs/ip-addresses-and-domains-for-atlassian-cloud-products/#Outgoing-Connections), in order to verify that the webhook came from Bitbucket you must whitelist a set of IP ranges: 88 | 89 | ```json 90 | [ 91 | { 92 | "id": "webhook", 93 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 94 | "command-working-directory": "/home/adnan/go", 95 | "pass-arguments-to-command": 96 | [ 97 | { 98 | "source": "payload", 99 | "name": "actor.username" 100 | } 101 | ], 102 | "trigger-rule": 103 | { 104 | "or": 105 | [ 106 | { "match": { "type": "ip-whitelist", "ip-range": "13.52.5.96/28" } }, 107 | { "match": { "type": "ip-whitelist", "ip-range": "13.236.8.224/28" } }, 108 | { "match": { "type": "ip-whitelist", "ip-range": "18.136.214.96/28" } }, 109 | { "match": { "type": "ip-whitelist", "ip-range": "18.184.99.224/28" } }, 110 | { "match": { "type": "ip-whitelist", "ip-range": "18.234.32.224/28" } }, 111 | { "match": { "type": "ip-whitelist", "ip-range": "18.246.31.224/28" } }, 112 | { "match": { "type": "ip-whitelist", "ip-range": "52.215.192.224/28" } }, 113 | { "match": { "type": "ip-whitelist", "ip-range": "104.192.137.240/28" } }, 114 | { "match": { "type": "ip-whitelist", "ip-range": "104.192.138.240/28" } }, 115 | { "match": { "type": "ip-whitelist", "ip-range": "104.192.140.240/28" } }, 116 | { "match": { "type": "ip-whitelist", "ip-range": "104.192.142.240/28" } }, 117 | { "match": { "type": "ip-whitelist", "ip-range": "104.192.143.240/28" } }, 118 | { "match": { "type": "ip-whitelist", "ip-range": "185.166.143.240/28" } }, 119 | { "match": { "type": "ip-whitelist", "ip-range": "185.166.142.240/28" } } 120 | ] 121 | } 122 | } 123 | ] 124 | ``` 125 | 126 | ## Incoming Gitlab Webhook 127 | Gitlab provides webhooks for many kinds of events. 128 | Refer to this URL for example request body content: [gitlab-ce/integrations/webhooks](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/project/integrations/webhooks.md) 129 | Values in the request body can be accessed in the command or to the match rule by referencing 'payload' as the source: 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 | ```json 163 | [ 164 | { 165 | "id": "webhook", 166 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 167 | "command-working-directory": "/home/adnan/go", 168 | "pass-arguments-to-command": 169 | [ 170 | { 171 | "source": "payload", 172 | "name": "head_commit.id" 173 | }, 174 | { 175 | "source": "payload", 176 | "name": "pusher.name" 177 | }, 178 | { 179 | "source": "payload", 180 | "name": "pusher.email" 181 | } 182 | ], 183 | "trigger-rule": 184 | { 185 | "and": 186 | [ 187 | { 188 | "match": 189 | { 190 | "type": "payload-hmac-sha256", 191 | "secret": "mysecret", 192 | "parameter": 193 | { 194 | "source": "header", 195 | "name": "X-Gogs-Signature" 196 | } 197 | } 198 | }, 199 | { 200 | "match": 201 | { 202 | "type": "value", 203 | "value": "refs/heads/master", 204 | "parameter": 205 | { 206 | "source": "payload", 207 | "name": "ref" 208 | } 209 | } 210 | } 211 | ] 212 | } 213 | } 214 | ] 215 | ``` 216 | ## Incoming Gitea webhook 217 | ```json 218 | [ 219 | { 220 | "id": "webhook", 221 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 222 | "command-working-directory": "/home/adnan/go", 223 | "pass-arguments-to-command": 224 | [ 225 | { 226 | "source": "payload", 227 | "name": "head_commit.id" 228 | }, 229 | { 230 | "source": "payload", 231 | "name": "pusher.name" 232 | }, 233 | { 234 | "source": "payload", 235 | "name": "pusher.email" 236 | } 237 | ], 238 | "trigger-rule": 239 | { 240 | "and": 241 | [ 242 | { 243 | "match": 244 | { 245 | "type": "value", 246 | "value": "mysecret", 247 | "parameter": 248 | { 249 | "source": "payload", 250 | "name": "secret" 251 | } 252 | } 253 | }, 254 | { 255 | "match": 256 | { 257 | "type": "value", 258 | "value": "refs/heads/master", 259 | "parameter": 260 | { 261 | "source": "payload", 262 | "name": "ref" 263 | } 264 | } 265 | } 266 | ] 267 | } 268 | } 269 | ] 270 | ``` 271 | 272 | ## Slack slash command 273 | ```json 274 | [ 275 | { 276 | "id": "redeploy-webhook", 277 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 278 | "command-working-directory": "/home/adnan/go", 279 | "response-message": "Executing redeploy script", 280 | "trigger-rule": 281 | { 282 | "match": 283 | { 284 | "type": "value", 285 | "value": "", 286 | "parameter": 287 | { 288 | "source": "payload", 289 | "name": "token" 290 | } 291 | } 292 | } 293 | } 294 | ] 295 | ``` 296 | 297 | ## A simple webhook with a secret key in GET query 298 | 299 | __Not recommended in production due to low security__ 300 | 301 | `example.com:9000/hooks/simple-one` - won't work 302 | `example.com:9000/hooks/simple-one?token=42` - will work 303 | 304 | ```json 305 | [ 306 | { 307 | "id": "simple-one", 308 | "execute-command": "/path/to/command.sh", 309 | "response-message": "Executing simple webhook...", 310 | "trigger-rule": 311 | { 312 | "match": 313 | { 314 | "type": "value", 315 | "value": "42", 316 | "parameter": 317 | { 318 | "source": "url", 319 | "name": "token" 320 | } 321 | } 322 | } 323 | } 324 | ] 325 | ``` 326 | 327 | ## JIRA Webhooks 328 | [Guide by @perfecto25](https://sites.google.com/site/mrxpalmeiras/more/jira-webhooks) 329 | 330 | ## Pass File-to-command sample 331 | 332 | ### Webhook configuration 333 | 334 | ```json 335 | [ 336 | { 337 | "id": "test-file-webhook", 338 | "execute-command": "/bin/ls", 339 | "command-working-directory": "/tmp", 340 | "pass-file-to-command": 341 | [ 342 | { 343 | "source": "payload", 344 | "name": "binary", 345 | "envname": "ENV_VARIABLE", // to use $ENV_VARIABLE in execute-command 346 | // if not defined, $HOOK_BINARY will be provided 347 | "base64decode": true, // defaults to false 348 | } 349 | ], 350 | "include-command-output-in-response": true 351 | } 352 | ] 353 | ``` 354 | 355 | ### Sample client usage 356 | 357 | Store the following file as `testRequest.json`. 358 | 359 | ```json 360 | {"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="} 361 | ``` 362 | 363 | use then the curl tool to execute a request to the webhook. 364 | 365 | ```sh 366 | #!/bin/bash 367 | curl -H "Content-Type:application/json" -X POST -d @testRequest.json \ 368 | http://localhost:9000/hooks/test-file-webhook 369 | ``` 370 | 371 | or in a single line, using https://github.com/jpmens/jo to generate the JSON code 372 | ```console 373 | jo binary=%filename.zip | curl -H "Content-Type:application/json" -X POST -d @- \ 374 | http://localhost:9000/hooks/test-file-webhook 375 | ``` 376 | 377 | 378 | ## Incoming Scalr Webhook 379 | [Guide by @hassanbabaie] 380 | Scalr makes webhook calls based on an event to a configured webhook endpoint (for example Host Down, Host Up). Webhook endpoints are URLs where Scalr will deliver Webhook notifications. 381 | Scalr assigns a unique signing key for every configured webhook endpoint. 382 | Refer to this URL for information on how to setup the webhook call on the Scalr side: [Scalr Wiki Webhooks](https://scalr-wiki.atlassian.net/wiki/spaces/docs/pages/6193173/Webhooks) 383 | In order to leverage the Signing Key for additional authentication/security you must configure the trigger rule with a match type of "scalr-signature". 384 | 385 | ```json 386 | [ 387 | { 388 | "id": "redeploy-webhook", 389 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 390 | "command-working-directory": "/home/adnan/go", 391 | "include-command-output-in-response": true, 392 | "trigger-rule": 393 | { 394 | "match": 395 | { 396 | "type": "scalr-signature", 397 | "secret": "Scalr-provided signing key" 398 | } 399 | }, 400 | "pass-environment-to-command": 401 | [ 402 | { 403 | "envname": "EVENT_NAME", 404 | "source": "payload", 405 | "name": "eventName" 406 | }, 407 | { 408 | "envname": "SERVER_HOSTNAME", 409 | "source": "payload", 410 | "name": "data.SCALR_SERVER_HOSTNAME" 411 | } 412 | ] 413 | } 414 | ] 415 | 416 | ``` 417 | 418 | ## Travis CI webhook 419 | Travis sends webhooks as `payload=`, so the payload needs to be parsed as JSON. Here is an example to run on successful builds of the master branch. 420 | 421 | ```json 422 | [ 423 | { 424 | "id": "deploy", 425 | "execute-command": "/root/my-server/deployment.sh", 426 | "command-working-directory": "/root/my-server", 427 | "parse-parameters-as-json": [ 428 | { 429 | "source": "payload", 430 | "name": "payload" 431 | } 432 | ], 433 | "trigger-rule": 434 | { 435 | "and": 436 | [ 437 | { 438 | "match": 439 | { 440 | "type": "value", 441 | "value": "passed", 442 | "parameter": { 443 | "name": "payload.state", 444 | "source": "payload" 445 | } 446 | } 447 | }, 448 | { 449 | "match": 450 | { 451 | "type": "value", 452 | "value": "master", 453 | "parameter": { 454 | "name": "payload.branch", 455 | "source": "payload" 456 | } 457 | } 458 | } 459 | ] 460 | } 461 | } 462 | ] 463 | ``` 464 | 465 | ## JSON Array Payload 466 | 467 | If the JSON payload is an array instead of an object, `webhook` will process the payload and place it into a "root" object. 468 | Therefore, references to payload values must begin with `root.`. 469 | 470 | For example, given the following payload (taken from the Sendgrid Event Webhook documentation): 471 | ```json 472 | [ 473 | { 474 | "email": "example@test.com", 475 | "timestamp": 1513299569, 476 | "smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>", 477 | "event": "processed", 478 | "category": "cat facts", 479 | "sg_event_id": "sg_event_id", 480 | "sg_message_id": "sg_message_id" 481 | }, 482 | { 483 | "email": "example@test.com", 484 | "timestamp": 1513299569, 485 | "smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>", 486 | "event": "deferred", 487 | "category": "cat facts", 488 | "sg_event_id": "sg_event_id", 489 | "sg_message_id": "sg_message_id", 490 | "response": "400 try again later", 491 | "attempt": "5" 492 | } 493 | ] 494 | ``` 495 | 496 | A reference to the second item in the array would look like this: 497 | ```json 498 | [ 499 | { 500 | "id": "sendgrid", 501 | "execute-command": "{{ .Hookecho }}", 502 | "trigger-rule": { 503 | "match": { 504 | "type": "value", 505 | "parameter": { 506 | "source": "payload", 507 | "name": "root.1.event" 508 | }, 509 | "value": "deferred" 510 | } 511 | } 512 | } 513 | ] 514 | ``` 515 | 516 | ## XML Payload 517 | 518 | Given the following payload: 519 | 520 | ```xml 521 | 522 | 523 | 524 | 525 | 526 | 527 | Hello!! 528 | 529 | 530 | ``` 531 | 532 | ```json 533 | [ 534 | { 535 | "id": "deploy", 536 | "execute-command": "/root/my-server/deployment.sh", 537 | "command-working-directory": "/root/my-server", 538 | "trigger-rule": { 539 | "and": [ 540 | { 541 | "match": { 542 | "type": "value", 543 | "parameter": { 544 | "source": "payload", 545 | "name": "app.users.user.0.-name" 546 | }, 547 | "value": "Jeff" 548 | } 549 | }, 550 | { 551 | "match": { 552 | "type": "value", 553 | "parameter": { 554 | "source": "payload", 555 | "name": "app.messages.message.#text" 556 | }, 557 | "value": "Hello!!" 558 | } 559 | }, 560 | ], 561 | } 562 | } 563 | ] 564 | ``` 565 | 566 | ## Multipart Form Data 567 | 568 | Example of a [Plex Media Server webhook](https://support.plex.tv/articles/115002267687-webhooks/). 569 | The Plex Media Server will send two parts: payload and thumb. 570 | We only care about the payload part. 571 | 572 | ```json 573 | [ 574 | { 575 | "id": "plex", 576 | "execute-command": "play-command.sh", 577 | "parse-parameters-as-json": [ 578 | { 579 | "source": "payload", 580 | "name": "payload" 581 | } 582 | ], 583 | "trigger-rule": 584 | { 585 | "match": 586 | { 587 | "type": "value", 588 | "parameter": { 589 | "source": "payload", 590 | "name": "payload.event" 591 | }, 592 | "value": "media.play" 593 | } 594 | } 595 | } 596 | ] 597 | ``` 598 | 599 | Each part of a multipart form data body will have a `Content-Disposition` header. 600 | Some example headers: 601 | 602 | ``` 603 | Content-Disposition: form-data; name="payload" 604 | Content-Disposition: form-data; name="thumb"; filename="thumb.jpg" 605 | ``` 606 | 607 | We key off of the `name` attribute in the `Content-Disposition` value. 608 | 609 | ## Pass string arguments to command 610 | 611 | To pass simple string arguments to a command, use the `string` parameter source. 612 | The following example will pass two static string parameters ("-e 123123") to the 613 | `execute-command` before appending the `pusher.email` value from the payload: 614 | 615 | ```json 616 | [ 617 | { 618 | "id": "webhook", 619 | "execute-command": "/home/adnan/redeploy-go-webhook.sh", 620 | "command-working-directory": "/home/adnan/go", 621 | "pass-arguments-to-command": 622 | [ 623 | { 624 | "source": "string", 625 | "name": "-e" 626 | }, 627 | { 628 | "source": "string", 629 | "name": "123123" 630 | }, 631 | { 632 | "source": "payload", 633 | "name": "pusher.email" 634 | } 635 | ] 636 | } 637 | ] 638 | ``` 639 | 640 | ## Receive Synology DSM notifications 641 | 642 | It's possible to securely receive Synology push notifications via webhooks. 643 | Webhooks feature introduced in DSM 7.x seems to be incomplete & broken, but you can use Synology SMS notification service to push webhooks. To configure SMS notifications on DSM follow instructions found here: https://github.com/ryancurrah/synology-notifications this will allow you to set up everything needed for webhook to accept any and all notifications sent by Synology. During setup an 'api_key' is specified - you can generate your own 32-char string and use it as an authentication mechanism to secure your webhook. Additionally, you can specify what notifications to receive via this method by going and selecting the "SMS" checkboxes under topics of interes in DSM: Control Panel -> Notification -> Rules 644 | 645 | ```json 646 | [ 647 | { 648 | "id": "synology", 649 | "execute-command": "do-something.sh", 650 | "command-working-directory": "/opt/webhook-linux-amd64/synology", 651 | "response-message": "Request accepted", 652 | "pass-arguments-to-command": 653 | [ 654 | { 655 | "source": "payload", 656 | "name": "message" 657 | } 658 | ], 659 | "trigger-rule": 660 | { 661 | "match": 662 | { 663 | "type": "value", 664 | "value": "PUT_YOUR_API_KEY_HERE", 665 | "parameter": 666 | { 667 | "source": "header", 668 | "name": "api_key" 669 | } 670 | } 671 | } 672 | } 673 | ] 674 | ``` 675 | -------------------------------------------------------------------------------- /docs/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/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/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/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-classic/b3210fb5628f48660445a12ebf838541adb2233a/docs/logo/logo-1024x1024.jpg -------------------------------------------------------------------------------- /docs/logo/logo-600x600.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soulteary/webhook-classic/b3210fb5628f48660445a12ebf838541adb2233a/docs/logo/logo-600x600.jpg -------------------------------------------------------------------------------- /example/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/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/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/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 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/soulteary/webhook 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/clbanning/mxj v1.8.4 7 | github.com/dustin/go-humanize v1.0.1 8 | github.com/ghodss/yaml v1.0.0 9 | github.com/go-chi/chi/v5 v5.0.12 10 | github.com/gofrs/uuid v4.4.0+incompatible 11 | github.com/gorilla/mux v1.8.1 12 | github.com/stretchr/testify v1.8.4 13 | golang.org/x/sys v0.18.0 14 | gopkg.in/fsnotify.v1 v1.4.7 15 | ) 16 | 17 | require ( 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/fsnotify/fsnotify v1.6.0 // indirect 20 | github.com/kr/pretty v0.3.1 // indirect 21 | github.com/pmezard/go-difflib v1.0.0 // indirect 22 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 23 | gopkg.in/yaml.v2 v2.4.0 // indirect 24 | gopkg.in/yaml.v3 v3.0.1 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I= 2 | github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= 3 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 7 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 8 | github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 9 | github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 10 | github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= 11 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 12 | github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= 13 | github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 14 | github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= 15 | github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 16 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 17 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 18 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 19 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 20 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 21 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 22 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 23 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 24 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 25 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 26 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 27 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 28 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 29 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 30 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 31 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 32 | golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 33 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 34 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 35 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 36 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 37 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 38 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 39 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 40 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 41 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 42 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 43 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 44 | -------------------------------------------------------------------------------- /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 | 27 | const ( 28 | ENV_KEY_HOST = "HOST" 29 | ENV_KEY_PORT = "PORT" 30 | 31 | ENV_KEY_VERBOSE = "VERBOSE" 32 | ENV_KEY_DEBUG = "DEBUG" 33 | ENV_KEY_NO_PANIC = "NO_PANIC" 34 | ENV_KEY_LOG_PATH = "LOG_PATH" 35 | ENV_KEY_HOT_RELOAD = "HOT_RELOAD" 36 | 37 | ENV_KEY_HOOKS_URLPREFIX = "URL_PREFIX" 38 | ENV_KEY_HOOKS = "HOOKS" 39 | ENV_KEY_TEMPLATE = "TEMPLATE" 40 | ENV_KEY_HTTP_METHODS = "HTTP_METHODS" 41 | ENV_KEY_PID_FILE = "PID_FILE" 42 | ENV_KEY_X_REQUEST_ID = "X_REQUEST_ID" 43 | ENV_KEY_MAX_MPART_MEM = "MAX_MPART_MEM" 44 | ENV_KEY_GID = "GID" 45 | ENV_KEY_UID = "UID" 46 | ENV_KEY_HEADER = "HEADER" 47 | ) 48 | 49 | type AppFlags struct { 50 | Host string 51 | Port int 52 | Verbose bool 53 | LogPath string 54 | Debug bool 55 | NoPanic bool 56 | HotReload bool 57 | HooksURLPrefix string 58 | AsTemplate bool 59 | UseXRequestID bool 60 | XRequestIDLimit int 61 | MaxMultipartMem int64 62 | SetGID int 63 | SetUID int 64 | HttpMethods string 65 | PidPath string 66 | 67 | ShowVersion bool 68 | HooksFiles hook.HooksFiles 69 | ResponseHeaders hook.ResponseHeaders 70 | } 71 | -------------------------------------------------------------------------------- /internal/flags/flags.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/soulteary/webhook/internal/hook" 9 | "github.com/soulteary/webhook/internal/rules" 10 | ) 11 | 12 | func ParseEnvs() AppFlags { 13 | var flags AppFlags 14 | flags.Host = GetEnvStr(ENV_KEY_HOST, DEFAULT_HOST) 15 | flags.Port = GetEnvInt(ENV_KEY_PORT, DEFAULT_PORT) 16 | flags.Verbose = GetEnvBool(ENV_KEY_VERBOSE, DEFAULT_ENABLE_VERBOSE) 17 | flags.LogPath = GetEnvStr(ENV_KEY_LOG_PATH, DEFAULT_LOG_PATH) 18 | flags.Debug = GetEnvBool(ENV_KEY_DEBUG, DEFAULT_ENABLE_DEBUG) 19 | flags.NoPanic = GetEnvBool(ENV_KEY_NO_PANIC, DEFAULT_ENABLE_NO_PANIC) 20 | flags.HotReload = GetEnvBool(ENV_KEY_HOT_RELOAD, DEFAULT_ENABLE_HOT_RELOAD) 21 | flags.HooksURLPrefix = GetEnvStr(ENV_KEY_HOOKS_URLPREFIX, DEFAULT_URL_PREFIX) 22 | flags.AsTemplate = GetEnvBool(ENV_KEY_TEMPLATE, DEFAULT_ENABLE_PARSE_TEMPLATE) 23 | flags.UseXRequestID = GetEnvBool(ENV_KEY_X_REQUEST_ID, DEFAULT_ENABLE_X_REQUEST_ID) 24 | flags.XRequestIDLimit = GetEnvInt(ENV_KEY_X_REQUEST_ID, DEFAULT_X_REQUEST_ID_LIMIT) 25 | flags.MaxMultipartMem = int64(GetEnvInt(ENV_KEY_MAX_MPART_MEM, DEFAULT_MAX_MPART_MEM)) 26 | flags.SetGID = GetEnvInt(ENV_KEY_GID, DEFAULT_GID) 27 | flags.SetUID = GetEnvInt(ENV_KEY_UID, DEFAULT_UID) 28 | flags.HttpMethods = GetEnvStr(ENV_KEY_HTTP_METHODS, DEFAULT_HTTP_METHODS) 29 | flags.PidPath = GetEnvStr(ENV_KEY_PID_FILE, DEFAULT_PID_FILE) 30 | 31 | hooks := strings.Split(GetEnvStr(ENV_KEY_HOOKS, ""), ",") 32 | var hooksFiles hook.HooksFiles 33 | for _, hook := range hooks { 34 | err := hooksFiles.Set(hook) 35 | if err != nil { 36 | fmt.Println("Error parsing hooks from environment variable: ", err) 37 | } 38 | } 39 | if len(hooksFiles) > 0 { 40 | flags.HooksFiles = hooksFiles 41 | } 42 | return flags 43 | } 44 | 45 | func ParseCLI(flags AppFlags) AppFlags { 46 | var ( 47 | Host = flag.String("ip", DEFAULT_HOST, "ip the webhook should serve hooks on") 48 | Port = flag.Int("port", DEFAULT_PORT, "port the webhook should serve hooks on") 49 | Verbose = flag.Bool("verbose", DEFAULT_ENABLE_VERBOSE, "show verbose output") 50 | LogPath = flag.String("logfile", DEFAULT_LOG_PATH, "send log output to a file; implicitly enables verbose logging") 51 | Debug = flag.Bool("debug", DEFAULT_ENABLE_DEBUG, "show debug output") 52 | NoPanic = flag.Bool("nopanic", DEFAULT_ENABLE_NO_PANIC, "do not panic if hooks cannot be loaded when webhook is not running in verbose mode") 53 | HotReload = flag.Bool("hotreload", DEFAULT_ENABLE_HOT_RELOAD, "watch hooks file for changes and reload them automatically") 54 | HooksURLPrefix = flag.String("urlprefix", DEFAULT_URL_PREFIX, "url prefix to use for served hooks (protocol://yourserver:port/PREFIX/:hook-id)") 55 | AsTemplate = flag.Bool("template", DEFAULT_ENABLE_PARSE_TEMPLATE, "parse hooks file as a Go template") 56 | UseXRequestID = flag.Bool("x-request-id", DEFAULT_ENABLE_X_REQUEST_ID, "use X-Request-Id header, if present, as request ID") 57 | XRequestIDLimit = flag.Int("x-request-id-limit", DEFAULT_X_REQUEST_ID_LIMIT, "truncate X-Request-Id header to limit; default no limit") 58 | MaxMultipartMem = flag.Int64("max-multipart-mem", DEFAULT_MAX_MPART_MEM, "maximum memory in bytes for parsing multipart form data before disk caching") 59 | SetGID = flag.Int("setgid", DEFAULT_GID, "set group ID after opening listening port; must be used with setuid") 60 | SetUID = flag.Int("setuid", DEFAULT_UID, "set user ID after opening listening port; must be used with setgid") 61 | HttpMethods = flag.String("http-methods", DEFAULT_HTTP_METHODS, `set default allowed HTTP methods (ie. "POST"); separate methods with comma`) 62 | PidPath = flag.String("pidfile", DEFAULT_PID_FILE, "create PID file at the given path") 63 | 64 | ShowVersion = flag.Bool("version", false, "display webhook version and quit") 65 | ResponseHeaders hook.ResponseHeaders 66 | ) 67 | 68 | hooksFiles := rules.HooksFiles 69 | flag.Var(&hooksFiles, "hooks", "path to the json file containing defined hooks the webhook should serve, use multiple times to load from different files") 70 | flag.Var(&ResponseHeaders, "header", "response header to return, specified in format name=value, use multiple times to set multiple headers") 71 | 72 | flag.Parse() 73 | 74 | if *Host != DEFAULT_HOST { 75 | flags.Host = *Host 76 | } 77 | 78 | if *Port != DEFAULT_PORT { 79 | flags.Port = *Port 80 | } 81 | 82 | if *Verbose != DEFAULT_ENABLE_VERBOSE { 83 | flags.Verbose = *Verbose 84 | } 85 | 86 | if *LogPath != DEFAULT_LOG_PATH { 87 | flags.LogPath = *LogPath 88 | } 89 | 90 | if *Debug != DEFAULT_ENABLE_DEBUG { 91 | flags.Debug = *Debug 92 | } 93 | 94 | if *NoPanic != DEFAULT_ENABLE_NO_PANIC { 95 | flags.NoPanic = *NoPanic 96 | } 97 | 98 | if *HotReload != DEFAULT_ENABLE_HOT_RELOAD { 99 | flags.HotReload = *HotReload 100 | } 101 | 102 | if *HooksURLPrefix != DEFAULT_URL_PREFIX { 103 | flags.HooksURLPrefix = *HooksURLPrefix 104 | } 105 | 106 | if *AsTemplate != DEFAULT_ENABLE_PARSE_TEMPLATE { 107 | flags.AsTemplate = *AsTemplate 108 | } 109 | 110 | if *UseXRequestID != DEFAULT_ENABLE_X_REQUEST_ID { 111 | flags.UseXRequestID = *UseXRequestID 112 | } 113 | 114 | if *XRequestIDLimit != DEFAULT_X_REQUEST_ID_LIMIT { 115 | flags.XRequestIDLimit = *XRequestIDLimit 116 | } 117 | 118 | if *MaxMultipartMem != DEFAULT_MAX_MPART_MEM { 119 | flags.MaxMultipartMem = *MaxMultipartMem 120 | } 121 | 122 | if *SetGID != DEFAULT_GID { 123 | flags.SetGID = *SetGID 124 | } 125 | 126 | if *SetUID != DEFAULT_UID { 127 | flags.SetUID = *SetUID 128 | } 129 | 130 | if *HttpMethods != DEFAULT_HTTP_METHODS { 131 | flags.HttpMethods = *HttpMethods 132 | } 133 | 134 | if *PidPath != DEFAULT_PID_FILE { 135 | flags.PidPath = *PidPath 136 | } 137 | 138 | if *ShowVersion { 139 | flags.ShowVersion = true 140 | } 141 | 142 | if len(hooksFiles) > 0 { 143 | flags.HooksFiles = append(flags.HooksFiles, hooksFiles...) 144 | } 145 | rules.HooksFiles = flags.HooksFiles 146 | 147 | if len(ResponseHeaders) > 0 { 148 | flags.ResponseHeaders = ResponseHeaders 149 | } 150 | return flags 151 | } 152 | 153 | func Parse() AppFlags { 154 | envs := ParseEnvs() 155 | cli := ParseCLI(envs) 156 | return cli 157 | } 158 | -------------------------------------------------------------------------------- /internal/flags/fn.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | func GetEnvStr(key, defaultValue string) string { 10 | value := strings.TrimSpace(os.Getenv(key)) 11 | if value == "" { 12 | return defaultValue 13 | } 14 | return value 15 | } 16 | 17 | func GetEnvBool(key string, defaultValue bool) bool { 18 | value := strings.TrimSpace(os.Getenv(key)) 19 | if value == "" { 20 | return defaultValue 21 | } 22 | if value == "true" || value == "1" || value == "on" || value == "yes" { 23 | return true 24 | } 25 | return false 26 | } 27 | 28 | func GetEnvInt(key string, defaultValue int) int { 29 | value := strings.TrimSpace(os.Getenv(key)) 30 | if value == "" { 31 | return defaultValue 32 | } 33 | 34 | i, err := strconv.Atoi(value) 35 | if err != nil { 36 | return defaultValue 37 | } 38 | return i 39 | } 40 | -------------------------------------------------------------------------------- /internal/hook/hook.go: -------------------------------------------------------------------------------- 1 | package hook 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "path/filepath" 7 | 8 | // #nosec 9 | "crypto/sha1" 10 | "crypto/sha256" 11 | "crypto/sha512" 12 | "crypto/subtle" 13 | "encoding/base64" 14 | "encoding/hex" 15 | "encoding/json" 16 | "errors" 17 | "fmt" 18 | "hash" 19 | "log" 20 | "math" 21 | "net" 22 | "net/textproto" 23 | "os" 24 | "reflect" 25 | "regexp" 26 | "strconv" 27 | "strings" 28 | "text/template" 29 | "time" 30 | 31 | "github.com/ghodss/yaml" 32 | ) 33 | 34 | // Constants used to specify the parameter source 35 | const ( 36 | SourceHeader string = "header" 37 | SourceQuery string = "url" 38 | SourceQueryAlias string = "query" 39 | SourcePayload string = "payload" 40 | SourceRawRequestBody string = "raw-request-body" 41 | SourceRequest string = "request" 42 | SourceString string = "string" 43 | SourceEntirePayload string = "entire-payload" 44 | SourceEntireQuery string = "entire-query" 45 | SourceEntireHeaders string = "entire-headers" 46 | ) 47 | 48 | const ( 49 | // EnvNamespace is the prefix used for passing arguments into the command 50 | // environment. 51 | EnvNamespace string = "HOOK_" 52 | ) 53 | 54 | // ParameterNodeError describes an error walking a parameter node. 55 | type ParameterNodeError struct { 56 | Key string 57 | } 58 | 59 | func (e *ParameterNodeError) Error() string { 60 | if e == nil { 61 | return "" 62 | } 63 | return fmt.Sprintf("parameter node not found: %s", e.Key) 64 | } 65 | 66 | // IsParameterNodeError returns whether err is of type ParameterNodeError. 67 | func IsParameterNodeError(err error) bool { 68 | switch err.(type) { 69 | case *ParameterNodeError: 70 | return true 71 | default: 72 | return false 73 | } 74 | } 75 | 76 | // SignatureError describes an invalid payload signature passed to Hook. 77 | type SignatureError struct { 78 | Signature string 79 | Signatures []string 80 | 81 | EmptyPayload bool 82 | } 83 | 84 | func (e *SignatureError) Error() string { 85 | if e == nil { 86 | return "" 87 | } 88 | 89 | var empty string 90 | if e.EmptyPayload { 91 | empty = " on empty payload" 92 | } 93 | 94 | if e.Signatures != nil { 95 | return fmt.Sprintf("invalid payload signatures %s%s", e.Signatures, empty) 96 | } 97 | 98 | return fmt.Sprintf("invalid payload signature %s%s", e.Signature, empty) 99 | } 100 | 101 | // IsSignatureError returns whether err is of type SignatureError. 102 | func IsSignatureError(err error) bool { 103 | switch err.(type) { 104 | case *SignatureError: 105 | return true 106 | default: 107 | return false 108 | } 109 | } 110 | 111 | // ArgumentError describes an invalid argument passed to Hook. 112 | type ArgumentError struct { 113 | Argument Argument 114 | } 115 | 116 | func (e *ArgumentError) Error() string { 117 | if e == nil { 118 | return "" 119 | } 120 | return fmt.Sprintf("couldn't retrieve argument for %+v", e.Argument) 121 | } 122 | 123 | // SourceError describes an invalid source passed to Hook. 124 | type SourceError struct { 125 | Argument Argument 126 | } 127 | 128 | func (e *SourceError) Error() string { 129 | if e == nil { 130 | return "" 131 | } 132 | return fmt.Sprintf("invalid source for argument %+v", e.Argument) 133 | } 134 | 135 | // ParseError describes an error parsing user input. 136 | type ParseError struct { 137 | Err error 138 | } 139 | 140 | func (e *ParseError) Error() string { 141 | if e == nil { 142 | return "" 143 | } 144 | return e.Err.Error() 145 | } 146 | 147 | // ExtractCommaSeparatedValues will extract the values matching the key. 148 | func ExtractCommaSeparatedValues(source, prefix string) []string { 149 | parts := strings.Split(source, ",") 150 | values := make([]string, 0) 151 | for _, part := range parts { 152 | if strings.HasPrefix(part, prefix) { 153 | values = append(values, strings.TrimPrefix(part, prefix)) 154 | } 155 | } 156 | 157 | return values 158 | } 159 | 160 | // ExtractSignatures will extract all the signatures from the source. 161 | func ExtractSignatures(source, prefix string) []string { 162 | // If there are multiple possible matches, let the comma seperated extractor 163 | // do it's work. 164 | if strings.Contains(source, ",") { 165 | return ExtractCommaSeparatedValues(source, prefix) 166 | } 167 | 168 | // There were no commas, so just trim the prefix (if it even exists) and 169 | // pass it back. 170 | return []string{ 171 | strings.TrimPrefix(source, prefix), 172 | } 173 | } 174 | 175 | // ValidateMAC will verify that the expected mac for the given hash will match 176 | // the one provided. 177 | func ValidateMAC(payload []byte, mac hash.Hash, signatures []string) (string, error) { 178 | // Write the payload to the provided hash. 179 | _, err := mac.Write(payload) 180 | if err != nil { 181 | return "", err 182 | } 183 | 184 | actualMAC := hex.EncodeToString(mac.Sum(nil)) 185 | 186 | for _, signature := range signatures { 187 | if hmac.Equal([]byte(signature), []byte(actualMAC)) { 188 | return actualMAC, err 189 | } 190 | } 191 | 192 | e := &SignatureError{Signatures: signatures} 193 | if len(payload) == 0 { 194 | e.EmptyPayload = true 195 | } 196 | 197 | return actualMAC, e 198 | } 199 | 200 | // CheckPayloadSignature calculates and verifies SHA1 signature of the given payload 201 | func CheckPayloadSignature(payload []byte, secret, signature string) (string, error) { 202 | if secret == "" { 203 | return "", errors.New("signature validation secret can not be empty") 204 | } 205 | 206 | // Extract the signatures. 207 | signatures := ExtractSignatures(signature, "sha1=") 208 | 209 | // Validate the MAC. 210 | return ValidateMAC(payload, hmac.New(sha1.New, []byte(secret)), signatures) 211 | } 212 | 213 | // CheckPayloadSignature256 calculates and verifies SHA256 signature of the given payload 214 | func CheckPayloadSignature256(payload []byte, secret, signature string) (string, error) { 215 | if secret == "" { 216 | return "", errors.New("signature validation secret can not be empty") 217 | } 218 | 219 | // Extract the signatures. 220 | signatures := ExtractSignatures(signature, "sha256=") 221 | 222 | // Validate the MAC. 223 | return ValidateMAC(payload, hmac.New(sha256.New, []byte(secret)), signatures) 224 | } 225 | 226 | // CheckPayloadSignature512 calculates and verifies SHA512 signature of the given payload 227 | func CheckPayloadSignature512(payload []byte, secret, signature string) (string, error) { 228 | if secret == "" { 229 | return "", errors.New("signature validation secret can not be empty") 230 | } 231 | 232 | // Extract the signatures. 233 | signatures := ExtractSignatures(signature, "sha512=") 234 | 235 | // Validate the MAC. 236 | return ValidateMAC(payload, hmac.New(sha512.New, []byte(secret)), signatures) 237 | } 238 | 239 | func CheckMSTeamsSignature(r *Request, signingKey string) (bool, error) { 240 | if r.Headers == nil { 241 | return false, nil 242 | } 243 | 244 | // Check if the signing key is valid 245 | if signingKey == "" { 246 | return false, errors.New("signature validation key can not be empty") 247 | } 248 | secret, err := base64.StdEncoding.DecodeString(signingKey) 249 | if err != nil { 250 | return false, errors.New("signature validation key must be valid base64") 251 | } 252 | // Check if a valid HMAC header was provided 253 | if _, ok := r.Headers["Authorization"]; !ok { 254 | return false, nil 255 | } 256 | headerParts := strings.SplitN(r.Headers["Authorization"].(string), " ", 2) 257 | if len(headerParts) != 2 || headerParts[0] != "HMAC" { 258 | return false, errors.New("malformed 'Authorization' header") 259 | } 260 | providedSignature := headerParts[1] 261 | 262 | mac := hmac.New(sha256.New, secret) 263 | mac.Write(r.Body) 264 | expectedSignature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) 265 | if !hmac.Equal([]byte(providedSignature), []byte(expectedSignature)) { 266 | return false, &SignatureError{Signature: providedSignature} 267 | } 268 | return true, nil 269 | 270 | } 271 | 272 | func CheckScalrSignature(r *Request, signingKey string, checkDate bool) (bool, error) { 273 | if r.Headers == nil { 274 | return false, nil 275 | } 276 | 277 | // Check for the signature and date headers 278 | if _, ok := r.Headers["X-Signature"]; !ok { 279 | return false, nil 280 | } 281 | if _, ok := r.Headers["Date"]; !ok { 282 | return false, nil 283 | } 284 | if signingKey == "" { 285 | return false, errors.New("signature validation signing key can not be empty") 286 | } 287 | 288 | providedSignature := r.Headers["X-Signature"].(string) 289 | dateHeader := r.Headers["Date"].(string) 290 | mac := hmac.New(sha1.New, []byte(signingKey)) 291 | mac.Write(r.Body) 292 | mac.Write([]byte(dateHeader)) 293 | expectedSignature := hex.EncodeToString(mac.Sum(nil)) 294 | 295 | if !hmac.Equal([]byte(providedSignature), []byte(expectedSignature)) { 296 | return false, &SignatureError{Signature: providedSignature} 297 | } 298 | 299 | if !checkDate { 300 | return true, nil 301 | } 302 | // Example format: Fri 08 Sep 2017 11:24:32 UTC 303 | date, err := time.Parse("Mon 02 Jan 2006 15:04:05 MST", dateHeader) 304 | if err != nil { 305 | return false, err 306 | } 307 | now := time.Now() 308 | delta := math.Abs(now.Sub(date).Seconds()) 309 | 310 | if delta > 300 { 311 | return false, &SignatureError{Signature: "outdated"} 312 | } 313 | return true, nil 314 | } 315 | 316 | // CheckIPWhitelist makes sure the provided remote address (of the form IP:port) falls within the provided IP range 317 | // (in CIDR form or a single IP address). 318 | func CheckIPWhitelist(remoteAddr, ipRange string) (bool, error) { 319 | // Extract IP address from remote address. 320 | 321 | // IPv6 addresses will likely be surrounded by []. 322 | ip := strings.Trim(remoteAddr, " []") 323 | 324 | if i := strings.LastIndex(ip, ":"); i != -1 { 325 | ip = ip[:i] 326 | ip = strings.Trim(ip, " []") 327 | } 328 | 329 | parsedIP := net.ParseIP(ip) 330 | if parsedIP == nil { 331 | return false, fmt.Errorf("invalid IP address found in remote address '%s'", remoteAddr) 332 | } 333 | 334 | for _, r := range strings.Fields(ipRange) { 335 | // Extract IP range in CIDR form. If a single IP address is provided, turn it into CIDR form. 336 | 337 | if !strings.Contains(r, "/") { 338 | r = r + "/32" 339 | } 340 | 341 | _, cidr, err := net.ParseCIDR(r) 342 | if err != nil { 343 | return false, err 344 | } 345 | 346 | if cidr.Contains(parsedIP) { 347 | return true, nil 348 | } 349 | } 350 | 351 | return false, nil 352 | } 353 | 354 | // ReplaceParameter replaces parameter value with the passed value in the passed map 355 | // (please note you should pass pointer to the map, because we're modifying it) 356 | // based on the passed string 357 | func ReplaceParameter(s string, params, value interface{}) bool { 358 | if params == nil { 359 | return false 360 | } 361 | 362 | if paramsValue := reflect.ValueOf(params); paramsValue.Kind() == reflect.Slice { 363 | if paramsValueSliceLength := paramsValue.Len(); paramsValueSliceLength > 0 { 364 | if p := strings.SplitN(s, ".", 2); len(p) > 1 { 365 | index, err := strconv.ParseUint(p[0], 10, 64) 366 | 367 | if err != nil || uint64(paramsValueSliceLength) <= index { 368 | return false 369 | } 370 | 371 | return ReplaceParameter(p[1], params.([]interface{})[index], value) 372 | } 373 | } 374 | 375 | return false 376 | } 377 | 378 | if p := strings.SplitN(s, ".", 2); len(p) > 1 { 379 | if pValue, ok := params.(map[string]interface{})[p[0]]; ok { 380 | return ReplaceParameter(p[1], pValue, value) 381 | } 382 | } else { 383 | if _, ok := (*params.(*map[string]interface{}))[p[0]]; ok { 384 | (*params.(*map[string]interface{}))[p[0]] = value 385 | return true 386 | } 387 | } 388 | 389 | return false 390 | } 391 | 392 | // GetParameter extracts interface{} value based on the passed string 393 | func GetParameter(s string, params interface{}) (interface{}, error) { 394 | if params == nil { 395 | return nil, errors.New("no parameters") 396 | } 397 | 398 | paramsValue := reflect.ValueOf(params) 399 | 400 | switch paramsValue.Kind() { 401 | case reflect.Slice: 402 | paramsValueSliceLength := paramsValue.Len() 403 | if paramsValueSliceLength > 0 { 404 | 405 | if p := strings.SplitN(s, ".", 2); len(p) > 1 { 406 | index, err := strconv.ParseUint(p[0], 10, 64) 407 | 408 | if err != nil || uint64(paramsValueSliceLength) <= index { 409 | return nil, &ParameterNodeError{s} 410 | } 411 | 412 | return GetParameter(p[1], params.([]interface{})[index]) 413 | } 414 | 415 | index, err := strconv.ParseUint(s, 10, 64) 416 | 417 | if err != nil || uint64(paramsValueSliceLength) <= index { 418 | return nil, &ParameterNodeError{s} 419 | } 420 | 421 | return params.([]interface{})[index], nil 422 | } 423 | 424 | return nil, &ParameterNodeError{s} 425 | 426 | case reflect.Map: 427 | // Check for raw key 428 | if v, ok := params.(map[string]interface{})[s]; ok { 429 | return v, nil 430 | } 431 | 432 | // Check for dotted references 433 | p := strings.Split(s, ".") 434 | ref := "" 435 | for i := range p { 436 | if i == 0 { 437 | ref = p[i] 438 | } else { 439 | ref += "." + p[i] 440 | } 441 | if pValue, ok := params.(map[string]interface{})[ref]; ok { 442 | if i == len(p)-1 { 443 | return pValue, nil 444 | } else { 445 | return GetParameter(strings.Join(p[i+1:], "."), pValue) 446 | } 447 | } 448 | } 449 | } 450 | 451 | return nil, &ParameterNodeError{s} 452 | } 453 | 454 | // ExtractParameterAsString extracts value from interface{} as string based on 455 | // the passed string. Complex data types are rendered as JSON instead of the Go 456 | // Stringer format. 457 | func ExtractParameterAsString(s string, params interface{}) (string, error) { 458 | pValue, err := GetParameter(s, params) 459 | if err != nil { 460 | return "", err 461 | } 462 | 463 | switch v := reflect.ValueOf(pValue); v.Kind() { 464 | case reflect.Array, reflect.Map, reflect.Slice: 465 | r, err := json.Marshal(pValue) 466 | if err != nil { 467 | return "", err 468 | } 469 | 470 | return string(r), nil 471 | 472 | default: 473 | return fmt.Sprintf("%v", pValue), nil 474 | } 475 | } 476 | 477 | // Argument type specifies the parameter key name and the source it should 478 | // be extracted from 479 | type Argument struct { 480 | Source string `json:"source,omitempty"` 481 | Name string `json:"name,omitempty"` 482 | EnvName string `json:"envname,omitempty"` 483 | Base64Decode bool `json:"base64decode,omitempty"` 484 | } 485 | 486 | // Get Argument method returns the value for the Argument's key name 487 | // based on the Argument's source 488 | func (ha *Argument) Get(r *Request) (string, error) { 489 | var source *map[string]interface{} 490 | key := ha.Name 491 | 492 | switch ha.Source { 493 | case SourceHeader: 494 | source = &r.Headers 495 | key = textproto.CanonicalMIMEHeaderKey(ha.Name) 496 | 497 | case SourceQuery, SourceQueryAlias: 498 | source = &r.Query 499 | 500 | case SourcePayload: 501 | source = &r.Payload 502 | 503 | case SourceString: 504 | return ha.Name, nil 505 | 506 | case SourceRawRequestBody: 507 | return string(r.Body), nil 508 | 509 | case SourceRequest: 510 | if r == nil || r.RawRequest == nil { 511 | return "", errors.New("request is nil") 512 | } 513 | 514 | switch strings.ToLower(ha.Name) { 515 | case "remote-addr": 516 | return r.RawRequest.RemoteAddr, nil 517 | case "method": 518 | return r.RawRequest.Method, nil 519 | default: 520 | return "", fmt.Errorf("unsupported request key: %q", ha.Name) 521 | } 522 | 523 | case SourceEntirePayload: 524 | res, err := json.Marshal(&r.Payload) 525 | if err != nil { 526 | return "", err 527 | } 528 | 529 | return string(res), nil 530 | 531 | case SourceEntireHeaders: 532 | res, err := json.Marshal(&r.Headers) 533 | if err != nil { 534 | return "", err 535 | } 536 | 537 | return string(res), nil 538 | 539 | case SourceEntireQuery: 540 | res, err := json.Marshal(&r.Query) 541 | if err != nil { 542 | return "", err 543 | } 544 | 545 | return string(res), nil 546 | } 547 | 548 | if source != nil { 549 | return ExtractParameterAsString(key, *source) 550 | } 551 | 552 | return "", errors.New("no source for value retrieval") 553 | } 554 | 555 | // Header is a structure containing header name and it's value 556 | type Header struct { 557 | Name string `json:"name"` 558 | Value string `json:"value"` 559 | } 560 | 561 | // ResponseHeaders is a slice of Header objects 562 | type ResponseHeaders []Header 563 | 564 | func (h *ResponseHeaders) String() string { 565 | // a 'hack' to display name=value in flag usage listing 566 | if len(*h) == 0 { 567 | return "name=value" 568 | } 569 | 570 | result := make([]string, len(*h)) 571 | 572 | for idx, responseHeader := range *h { 573 | result[idx] = fmt.Sprintf("%s=%s", responseHeader.Name, responseHeader.Value) 574 | } 575 | 576 | return strings.Join(result, ", ") 577 | } 578 | 579 | // Set method appends new Header object from header=value notation 580 | func (h *ResponseHeaders) Set(value string) error { 581 | splitResult := strings.SplitN(value, "=", 2) 582 | 583 | if len(splitResult) != 2 { 584 | return errors.New("header flag must be in name=value format") 585 | } 586 | 587 | *h = append(*h, Header{Name: splitResult[0], Value: splitResult[1]}) 588 | return nil 589 | } 590 | 591 | // HooksFiles is a slice of String 592 | type HooksFiles []string 593 | 594 | func (h *HooksFiles) String() string { 595 | if len(*h) == 0 { 596 | return "hooks.json" 597 | } 598 | 599 | return strings.Join(*h, ", ") 600 | } 601 | 602 | // Set method appends new string 603 | func (h *HooksFiles) Set(value string) error { 604 | *h = append(*h, value) 605 | return nil 606 | } 607 | 608 | // Hook type is a structure containing details for a single hook 609 | type Hook struct { 610 | ID string `json:"id,omitempty"` 611 | ExecuteCommand string `json:"execute-command,omitempty"` 612 | CommandWorkingDirectory string `json:"command-working-directory,omitempty"` 613 | ResponseMessage string `json:"response-message,omitempty"` 614 | ResponseHeaders ResponseHeaders `json:"response-headers,omitempty"` 615 | CaptureCommandOutput bool `json:"include-command-output-in-response,omitempty"` 616 | StreamCommandOutput bool `json:"stream-command-output,omitempty"` 617 | CaptureCommandOutputOnError bool `json:"include-command-output-in-response-on-error,omitempty"` 618 | PassEnvironmentToCommand []Argument `json:"pass-environment-to-command,omitempty"` 619 | PassArgumentsToCommand []Argument `json:"pass-arguments-to-command,omitempty"` 620 | PassFileToCommand []Argument `json:"pass-file-to-command,omitempty"` 621 | JSONStringParameters []Argument `json:"parse-parameters-as-json,omitempty"` 622 | TriggerRule *Rules `json:"trigger-rule,omitempty"` 623 | TriggerRuleMismatchHttpResponseCode int `json:"trigger-rule-mismatch-http-response-code,omitempty"` 624 | TriggerSignatureSoftFailures bool `json:"trigger-signature-soft-failures,omitempty"` 625 | IncomingPayloadContentType string `json:"incoming-payload-content-type,omitempty"` 626 | SuccessHttpResponseCode int `json:"success-http-response-code,omitempty"` 627 | HTTPMethods []string `json:"http-methods"` 628 | } 629 | 630 | // ParseJSONParameters decodes specified arguments to JSON objects and replaces the 631 | // string with the newly created object 632 | func (h *Hook) ParseJSONParameters(r *Request) []error { 633 | errors := make([]error, 0) 634 | 635 | for i := range h.JSONStringParameters { 636 | arg, err := h.JSONStringParameters[i].Get(r) 637 | if err != nil { 638 | errors = append(errors, &ArgumentError{h.JSONStringParameters[i]}) 639 | } else { 640 | var newArg map[string]interface{} 641 | 642 | decoder := json.NewDecoder(strings.NewReader(string(arg))) 643 | decoder.UseNumber() 644 | 645 | err := decoder.Decode(&newArg) 646 | if err != nil { 647 | errors = append(errors, &ParseError{err}) 648 | continue 649 | } 650 | 651 | var source *map[string]interface{} 652 | 653 | switch h.JSONStringParameters[i].Source { 654 | case SourceHeader: 655 | source = &r.Headers 656 | case SourcePayload: 657 | source = &r.Payload 658 | case SourceQuery, SourceQueryAlias: 659 | source = &r.Query 660 | } 661 | 662 | if source != nil { 663 | key := h.JSONStringParameters[i].Name 664 | 665 | if h.JSONStringParameters[i].Source == SourceHeader { 666 | key = textproto.CanonicalMIMEHeaderKey(h.JSONStringParameters[i].Name) 667 | } 668 | 669 | ReplaceParameter(key, source, newArg) 670 | } else { 671 | errors = append(errors, &SourceError{h.JSONStringParameters[i]}) 672 | } 673 | } 674 | } 675 | 676 | if len(errors) > 0 { 677 | return errors 678 | } 679 | 680 | return nil 681 | } 682 | 683 | // ExtractCommandArguments creates a list of arguments, based on the 684 | // PassArgumentsToCommand property that is ready to be used with exec.Command() 685 | func (h *Hook) ExtractCommandArguments(r *Request) ([]string, []error) { 686 | args := make([]string, 0) 687 | errors := make([]error, 0) 688 | 689 | args = append(args, h.ExecuteCommand) 690 | 691 | for i := range h.PassArgumentsToCommand { 692 | arg, err := h.PassArgumentsToCommand[i].Get(r) 693 | if err != nil { 694 | args = append(args, "") 695 | errors = append(errors, &ArgumentError{h.PassArgumentsToCommand[i]}) 696 | continue 697 | } 698 | 699 | args = append(args, arg) 700 | } 701 | 702 | if len(errors) > 0 { 703 | return args, errors 704 | } 705 | 706 | return args, nil 707 | } 708 | 709 | // ExtractCommandArgumentsForEnv creates a list of arguments in key=value 710 | // format, based on the PassEnvironmentToCommand property that is ready to be used 711 | // with exec.Command(). 712 | func (h *Hook) ExtractCommandArgumentsForEnv(r *Request) ([]string, []error) { 713 | args := make([]string, 0) 714 | errors := make([]error, 0) 715 | for i := range h.PassEnvironmentToCommand { 716 | arg, err := h.PassEnvironmentToCommand[i].Get(r) 717 | if err != nil { 718 | errors = append(errors, &ArgumentError{h.PassEnvironmentToCommand[i]}) 719 | continue 720 | } 721 | 722 | if h.PassEnvironmentToCommand[i].EnvName != "" { 723 | // first try to use the EnvName if specified 724 | args = append(args, h.PassEnvironmentToCommand[i].EnvName+"="+arg) 725 | } else { 726 | // then fallback on the name 727 | args = append(args, EnvNamespace+h.PassEnvironmentToCommand[i].Name+"="+arg) 728 | } 729 | } 730 | 731 | if len(errors) > 0 { 732 | return args, errors 733 | } 734 | 735 | return args, nil 736 | } 737 | 738 | // FileParameter describes a pass-file-to-command instance to be stored as file 739 | type FileParameter struct { 740 | File *os.File 741 | EnvName string 742 | Data []byte 743 | } 744 | 745 | // ExtractCommandArgumentsForFile creates a list of arguments in key=value 746 | // format, based on the PassFileToCommand property that is ready to be used 747 | // with exec.Command(). 748 | func (h *Hook) ExtractCommandArgumentsForFile(r *Request) ([]FileParameter, []error) { 749 | args := make([]FileParameter, 0) 750 | errors := make([]error, 0) 751 | for i := range h.PassFileToCommand { 752 | arg, err := h.PassFileToCommand[i].Get(r) 753 | if err != nil { 754 | errors = append(errors, &ArgumentError{h.PassFileToCommand[i]}) 755 | continue 756 | } 757 | 758 | if h.PassFileToCommand[i].EnvName == "" { 759 | // if no environment-variable name is set, fall-back on the name 760 | log.Printf("no ENVVAR name specified, falling back to [%s]", EnvNamespace+strings.ToUpper(h.PassFileToCommand[i].Name)) 761 | h.PassFileToCommand[i].EnvName = EnvNamespace + strings.ToUpper(h.PassFileToCommand[i].Name) 762 | } 763 | 764 | var fileContent []byte 765 | if h.PassFileToCommand[i].Base64Decode { 766 | dec, err := base64.StdEncoding.DecodeString(arg) 767 | if err != nil { 768 | log.Printf("error decoding string [%s]", err) 769 | } 770 | fileContent = []byte(dec) 771 | } else { 772 | fileContent = []byte(arg) 773 | } 774 | 775 | args = append(args, FileParameter{EnvName: h.PassFileToCommand[i].EnvName, Data: fileContent}) 776 | } 777 | 778 | if len(errors) > 0 { 779 | return args, errors 780 | } 781 | 782 | return args, nil 783 | } 784 | 785 | // Hooks is an array of Hook objects 786 | type Hooks []Hook 787 | 788 | // LoadFromFile attempts to load hooks from the specified file, which 789 | // can be either JSON or YAML. The asTemplate parameter causes the file 790 | // contents to be parsed as a Go text/template prior to unmarshalling. 791 | func (h *Hooks) LoadFromFile(path string, asTemplate bool) error { 792 | if path == "" { 793 | return nil 794 | } 795 | 796 | // parse hook file for hooks 797 | file, e := os.ReadFile(filepath.Clean(path)) 798 | 799 | if e != nil { 800 | return e 801 | } 802 | 803 | if asTemplate { 804 | funcMap := template.FuncMap{"getenv": getenv} 805 | 806 | tmpl, err := template.New("hooks").Funcs(funcMap).Parse(string(file)) 807 | if err != nil { 808 | return err 809 | } 810 | 811 | var buf bytes.Buffer 812 | 813 | err = tmpl.Execute(&buf, nil) 814 | if err != nil { 815 | return err 816 | } 817 | 818 | file = buf.Bytes() 819 | } 820 | 821 | return yaml.Unmarshal(file, h) 822 | } 823 | 824 | // Append appends hooks unless the new hooks contain a hook with an ID that already exists 825 | func (h *Hooks) Append(other *Hooks) error { 826 | for _, hook := range *other { 827 | if h.Match(hook.ID) != nil { 828 | return fmt.Errorf("hook with ID %s is already defined", hook.ID) 829 | } 830 | 831 | *h = append(*h, hook) 832 | } 833 | 834 | return nil 835 | } 836 | 837 | // Match iterates through Hooks and returns first one that matches the given ID, 838 | // if no hook matches the given ID, nil is returned 839 | func (h *Hooks) Match(id string) *Hook { 840 | for i := range *h { 841 | if (*h)[i].ID == id { 842 | return &(*h)[i] 843 | } 844 | } 845 | 846 | return nil 847 | } 848 | 849 | // Rules is a structure that contains one of the valid rule types 850 | type Rules struct { 851 | And *AndRule `json:"and,omitempty"` 852 | Or *OrRule `json:"or,omitempty"` 853 | Not *NotRule `json:"not,omitempty"` 854 | Match *MatchRule `json:"match,omitempty"` 855 | } 856 | 857 | // Evaluate finds the first rule property that is not nil and returns the value 858 | // it evaluates to 859 | func (r Rules) Evaluate(req *Request) (bool, error) { 860 | switch { 861 | case r.And != nil: 862 | return r.And.Evaluate(req) 863 | case r.Or != nil: 864 | return r.Or.Evaluate(req) 865 | case r.Not != nil: 866 | return r.Not.Evaluate(req) 867 | case r.Match != nil: 868 | return r.Match.Evaluate(req) 869 | } 870 | 871 | return false, nil 872 | } 873 | 874 | // AndRule will evaluate to true if and only if all of the ChildRules evaluate to true 875 | type AndRule []Rules 876 | 877 | // Evaluate AndRule will return true if and only if all of ChildRules evaluate to true 878 | func (r AndRule) Evaluate(req *Request) (bool, error) { 879 | res := true 880 | 881 | for _, v := range r { 882 | rv, err := v.Evaluate(req) 883 | if err != nil { 884 | return false, err 885 | } 886 | 887 | res = res && rv 888 | if !res { 889 | return res, nil 890 | } 891 | } 892 | 893 | return res, nil 894 | } 895 | 896 | // OrRule will evaluate to true if any of the ChildRules evaluate to true 897 | type OrRule []Rules 898 | 899 | // Evaluate OrRule will return true if any of ChildRules evaluate to true 900 | func (r OrRule) Evaluate(req *Request) (bool, error) { 901 | res := false 902 | 903 | for _, v := range r { 904 | rv, err := v.Evaluate(req) 905 | if err != nil { 906 | if !IsParameterNodeError(err) { 907 | if !req.AllowSignatureErrors || (req.AllowSignatureErrors && !IsSignatureError(err)) { 908 | return false, err 909 | } 910 | } 911 | } 912 | 913 | res = res || rv 914 | if res { 915 | return res, nil 916 | } 917 | } 918 | 919 | return res, nil 920 | } 921 | 922 | // NotRule will evaluate to true if any and only if the ChildRule evaluates to false 923 | type NotRule Rules 924 | 925 | // Evaluate NotRule will return true if and only if ChildRule evaluates to false 926 | func (r NotRule) Evaluate(req *Request) (bool, error) { 927 | rv, err := Rules(r).Evaluate(req) 928 | return !rv, err 929 | } 930 | 931 | // MatchRule will evaluate to true based on the type 932 | type MatchRule struct { 933 | Type string `json:"type,omitempty"` 934 | Regex string `json:"regex,omitempty"` 935 | Secret string `json:"secret,omitempty"` 936 | Value string `json:"value,omitempty"` 937 | Parameter Argument `json:"parameter,omitempty"` 938 | IPRange string `json:"ip-range,omitempty"` 939 | } 940 | 941 | // Constants for the MatchRule type 942 | const ( 943 | MatchValue string = "value" 944 | MatchRegex string = "regex" 945 | MatchHMACSHA1 string = "payload-hmac-sha1" 946 | MatchHMACSHA256 string = "payload-hmac-sha256" 947 | MatchHMACSHA512 string = "payload-hmac-sha512" 948 | MatchHashSHA1 string = "payload-hash-sha1" 949 | MatchHashSHA256 string = "payload-hash-sha256" 950 | MatchHashSHA512 string = "payload-hash-sha512" 951 | IPWhitelist string = "ip-whitelist" 952 | ScalrSignature string = "scalr-signature" 953 | MSTeamsSignature string = "msteams-signature" 954 | ) 955 | 956 | // Evaluate MatchRule will return based on the type 957 | func (r MatchRule) Evaluate(req *Request) (bool, error) { 958 | if r.Type == IPWhitelist { 959 | return CheckIPWhitelist(req.RawRequest.RemoteAddr, r.IPRange) 960 | } 961 | if r.Type == ScalrSignature { 962 | return CheckScalrSignature(req, r.Secret, true) 963 | } 964 | if r.Type == MSTeamsSignature { 965 | return CheckMSTeamsSignature(req, r.Secret) 966 | } 967 | 968 | arg, err := r.Parameter.Get(req) 969 | if err == nil { 970 | switch r.Type { 971 | case MatchValue: 972 | return compare(arg, r.Value), nil 973 | case MatchRegex: 974 | return regexp.MatchString(r.Regex, arg) 975 | case MatchHashSHA1: 976 | log.Print(`warn: use of deprecated option payload-hash-sha1; use payload-hmac-sha1 instead`) 977 | fallthrough 978 | case MatchHMACSHA1: 979 | _, err := CheckPayloadSignature(req.Body, r.Secret, arg) 980 | return err == nil, err 981 | case MatchHashSHA256: 982 | log.Print(`warn: use of deprecated option payload-hash-sha256: use payload-hmac-sha256 instead`) 983 | fallthrough 984 | case MatchHMACSHA256: 985 | _, err := CheckPayloadSignature256(req.Body, r.Secret, arg) 986 | return err == nil, err 987 | case MatchHashSHA512: 988 | log.Print(`warn: use of deprecated option payload-hash-sha512: use payload-hmac-sha512 instead`) 989 | fallthrough 990 | case MatchHMACSHA512: 991 | _, err := CheckPayloadSignature512(req.Body, r.Secret, arg) 992 | return err == nil, err 993 | } 994 | } 995 | return false, err 996 | } 997 | 998 | // compare is a helper function for constant time string comparisons. 999 | func compare(a, b string) bool { 1000 | return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 1001 | } 1002 | 1003 | // getenv provides a template function to retrieve OS environment variables. 1004 | func getenv(s string) string { 1005 | return os.Getenv(s) 1006 | } 1007 | -------------------------------------------------------------------------------- /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" 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/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/gofrs/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.NewV4()).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/soulteary/webhook/internal/rules" 9 | fsnotify "gopkg.in/fsnotify.v1" 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/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/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/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | var ( 4 | Version = "3.1.1" 5 | ) 6 | -------------------------------------------------------------------------------- /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 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net" 9 | "net/http" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "strings" 14 | "time" 15 | 16 | "github.com/soulteary/webhook/internal/flags" 17 | "github.com/soulteary/webhook/internal/hook" 18 | "github.com/soulteary/webhook/internal/link" 19 | "github.com/soulteary/webhook/internal/middleware" 20 | "github.com/soulteary/webhook/internal/monitor" 21 | "github.com/soulteary/webhook/internal/pidfile" 22 | "github.com/soulteary/webhook/internal/platform" 23 | "github.com/soulteary/webhook/internal/rules" 24 | "github.com/soulteary/webhook/internal/version" 25 | 26 | chimiddleware "github.com/go-chi/chi/v5/middleware" 27 | "github.com/gorilla/mux" 28 | fsnotify "gopkg.in/fsnotify.v1" 29 | ) 30 | 31 | var ( 32 | watcher *fsnotify.Watcher 33 | signals chan os.Signal 34 | pidFile *pidfile.PIDFile 35 | ) 36 | 37 | type flushWriter struct { 38 | f http.Flusher 39 | w io.Writer 40 | } 41 | 42 | func (fw *flushWriter) Write(p []byte) (n int, err error) { 43 | n, err = fw.w.Write(p) 44 | if fw.f != nil { 45 | fw.f.Flush() 46 | } 47 | return 48 | } 49 | 50 | func main() { 51 | appFlags := flags.Parse() 52 | 53 | if appFlags.ShowVersion { 54 | fmt.Println("webhook version " + version.Version) 55 | os.Exit(0) 56 | } 57 | 58 | if (appFlags.SetUID != 0 || appFlags.SetGID != 0) && (appFlags.SetUID == 0 || appFlags.SetGID == 0) { 59 | fmt.Println("error: setuid and setgid options must be used together") 60 | os.Exit(1) 61 | } 62 | 63 | if appFlags.Debug || appFlags.LogPath != "" { 64 | appFlags.Verbose = true 65 | } 66 | 67 | if len(rules.HooksFiles) == 0 { 68 | rules.HooksFiles = append(rules.HooksFiles, "hooks.json") 69 | } 70 | 71 | // logQueue is a queue for log messages encountered during startup. We need 72 | // to queue the messages so that we can handle any privilege dropping and 73 | // log file opening prior to writing our first log message. 74 | var logQueue []string 75 | 76 | addr := fmt.Sprintf("%s:%d", appFlags.Host, appFlags.Port) 77 | 78 | // Open listener early so we can drop privileges. 79 | ln, err := net.Listen("tcp", addr) 80 | if err != nil { 81 | logQueue = append(logQueue, fmt.Sprintf("error listening on port: %s", err)) 82 | // we'll bail out below 83 | } 84 | 85 | if appFlags.SetUID != 0 { 86 | err := platform.DropPrivileges(appFlags.SetUID, appFlags.SetGID) 87 | if err != nil { 88 | logQueue = append(logQueue, fmt.Sprintf("error dropping privileges: %s", err)) 89 | // we'll bail out below 90 | } 91 | } 92 | 93 | if appFlags.LogPath != "" { 94 | file, err := os.OpenFile(appFlags.LogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) 95 | if err != nil { 96 | logQueue = append(logQueue, fmt.Sprintf("error opening log file %q: %v", appFlags.LogPath, err)) 97 | // we'll bail out below 98 | } else { 99 | log.SetOutput(file) 100 | } 101 | } 102 | 103 | log.SetPrefix("[webhook] ") 104 | log.SetFlags(log.Ldate | log.Ltime) 105 | 106 | if len(logQueue) != 0 { 107 | for i := range logQueue { 108 | log.Println(logQueue[i]) 109 | } 110 | 111 | os.Exit(1) 112 | } 113 | 114 | if !appFlags.Verbose { 115 | log.SetOutput(io.Discard) 116 | } 117 | 118 | // Create pidfile 119 | if appFlags.PidPath != "" { 120 | var err error 121 | 122 | pidFile, err = pidfile.New(appFlags.PidPath) 123 | if err != nil { 124 | log.Fatalf("Error creating pidfile: %v", err) 125 | } 126 | 127 | defer func() { 128 | // NOTE(moorereason): my testing shows that this doesn't work with 129 | // ^C, so we also do a Remove in the signal handler elsewhere. 130 | if nerr := pidFile.Remove(); nerr != nil { 131 | log.Print(nerr) 132 | } 133 | }() 134 | } 135 | 136 | log.Println("version " + version.Version + " starting") 137 | 138 | // set os signal watcher 139 | if appFlags.AsTemplate { 140 | platform.SetupSignals(signals, rules.ReloadAllHooksAsTemplate, pidFile) 141 | } else { 142 | platform.SetupSignals(signals, rules.ReloadAllHooksNotAsTemplate, pidFile) 143 | } 144 | 145 | // load and parse hooks 146 | for _, hooksFilePath := range rules.HooksFiles { 147 | log.Printf("attempting to load hooks from %s\n", hooksFilePath) 148 | 149 | newHooks := hook.Hooks{} 150 | 151 | err := newHooks.LoadFromFile(hooksFilePath, appFlags.AsTemplate) 152 | if err != nil { 153 | log.Printf("couldn't load hooks from file! %+v\n", err) 154 | } else { 155 | log.Printf("found %d hook(s) in file\n", len(newHooks)) 156 | 157 | for _, hook := range newHooks { 158 | if rules.MatchLoadedHook(hook.ID) != nil { 159 | log.Fatalf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!\n", hook.ID) 160 | } 161 | log.Printf("\tloaded: %s\n", hook.ID) 162 | } 163 | 164 | rules.LoadedHooksFromFiles[hooksFilePath] = newHooks 165 | } 166 | } 167 | 168 | newHooksFiles := rules.HooksFiles[:0] 169 | for _, filePath := range rules.HooksFiles { 170 | if _, ok := rules.LoadedHooksFromFiles[filePath]; ok { 171 | newHooksFiles = append(newHooksFiles, filePath) 172 | } 173 | } 174 | 175 | rules.HooksFiles = newHooksFiles 176 | 177 | if !appFlags.Verbose && !appFlags.NoPanic && rules.LenLoadedHooks() == 0 { 178 | log.SetOutput(os.Stdout) 179 | log.Fatalln("couldn't load any hooks from file!\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to start without the hooks, either use -verbose flag, or -nopanic") 180 | } 181 | 182 | if appFlags.HotReload { 183 | var err error 184 | 185 | watcher, err = fsnotify.NewWatcher() 186 | if err != nil { 187 | log.Fatal("error creating file watcher instance\n", err) 188 | } 189 | defer watcher.Close() 190 | 191 | for _, hooksFilePath := range rules.HooksFiles { 192 | // set up file watcher 193 | log.Printf("setting up file watcher for %s\n", hooksFilePath) 194 | 195 | err = watcher.Add(hooksFilePath) 196 | if err != nil { 197 | log.Print("error adding hooks file to the watcher\n", err) 198 | return 199 | } 200 | } 201 | 202 | go monitor.WatchForFileChange(watcher, appFlags.AsTemplate, appFlags.Verbose, appFlags.NoPanic, rules.ReloadHooks, rules.RemoveHooks) 203 | } 204 | 205 | r := mux.NewRouter() 206 | 207 | r.Use(middleware.RequestID( 208 | middleware.UseXRequestIDHeaderOption(appFlags.UseXRequestID), 209 | middleware.XRequestIDLimitOption(appFlags.XRequestIDLimit), 210 | )) 211 | r.Use(middleware.NewLogger()) 212 | r.Use(chimiddleware.Recoverer) 213 | 214 | if appFlags.Debug { 215 | r.Use(middleware.Dumper(log.Writer())) 216 | } 217 | 218 | // Clean up input 219 | appFlags.HttpMethods = strings.ToUpper(strings.ReplaceAll(appFlags.HttpMethods, " ", "")) 220 | 221 | hooksURL := link.MakeRoutePattern(&appFlags.HooksURLPrefix) 222 | 223 | r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { 224 | for _, responseHeader := range appFlags.ResponseHeaders { 225 | w.Header().Set(responseHeader.Name, responseHeader.Value) 226 | } 227 | 228 | fmt.Fprint(w, "OK") 229 | }) 230 | 231 | hookHandler := createHookHandler(appFlags) 232 | r.HandleFunc(hooksURL, hookHandler) 233 | 234 | // Create common HTTP server settings 235 | svr := &http.Server{ 236 | Addr: addr, 237 | Handler: r, 238 | ReadHeaderTimeout: 5 * time.Second, 239 | ReadTimeout: 5 * time.Second, 240 | } 241 | 242 | // Serve HTTP 243 | log.Printf("serving hooks on http://%s%s", addr, link.MakeHumanPattern(&appFlags.HooksURLPrefix)) 244 | log.Print(svr.Serve(ln)) 245 | } 246 | 247 | func createHookHandler(appFlags flags.AppFlags) func(w http.ResponseWriter, r *http.Request) { 248 | return func(w http.ResponseWriter, r *http.Request) { 249 | req := &hook.Request{ 250 | ID: middleware.GetReqID(r.Context()), 251 | RawRequest: r, 252 | } 253 | 254 | log.Printf("[%s] incoming HTTP %s request from %s\n", req.ID, r.Method, r.RemoteAddr) 255 | 256 | // TODO: rename this to avoid confusion with Request.ID 257 | id := mux.Vars(r)["id"] 258 | 259 | matchedHook := rules.MatchLoadedHook(id) 260 | if matchedHook == nil { 261 | w.WriteHeader(http.StatusNotFound) 262 | fmt.Fprint(w, "Hook not found.") 263 | return 264 | } 265 | 266 | // Check for allowed methods 267 | var allowedMethod bool 268 | 269 | switch { 270 | case len(matchedHook.HTTPMethods) != 0: 271 | for i := range matchedHook.HTTPMethods { 272 | // TODO(moorereason): refactor config loading and reloading to 273 | // sanitize these methods once at load time. 274 | if r.Method == strings.ToUpper(strings.TrimSpace(matchedHook.HTTPMethods[i])) { 275 | allowedMethod = true 276 | break 277 | } 278 | } 279 | case appFlags.HttpMethods != "": 280 | for _, v := range strings.Split(appFlags.HttpMethods, ",") { 281 | if r.Method == v { 282 | allowedMethod = true 283 | break 284 | } 285 | } 286 | default: 287 | allowedMethod = true 288 | } 289 | 290 | if !allowedMethod { 291 | w.WriteHeader(http.StatusMethodNotAllowed) 292 | log.Printf("[%s] HTTP %s method not allowed for hook %q", req.ID, r.Method, id) 293 | 294 | return 295 | } 296 | 297 | log.Printf("[%s] %s got matched\n", req.ID, id) 298 | 299 | for _, responseHeader := range appFlags.ResponseHeaders { 300 | w.Header().Set(responseHeader.Name, responseHeader.Value) 301 | } 302 | 303 | var err error 304 | 305 | // set contentType to IncomingPayloadContentType or header value 306 | req.ContentType = r.Header.Get("Content-Type") 307 | if len(matchedHook.IncomingPayloadContentType) != 0 { 308 | req.ContentType = matchedHook.IncomingPayloadContentType 309 | } 310 | 311 | isMultipart := strings.HasPrefix(req.ContentType, "multipart/form-data;") 312 | 313 | if !isMultipart { 314 | req.Body, err = io.ReadAll(r.Body) 315 | if err != nil { 316 | log.Printf("[%s] error reading the request body: %+v\n", req.ID, err) 317 | } 318 | } 319 | 320 | req.ParseHeaders(r.Header) 321 | req.ParseQuery(r.URL.Query()) 322 | 323 | switch { 324 | case strings.Contains(req.ContentType, "json"): 325 | err = req.ParseJSONPayload() 326 | if err != nil { 327 | log.Printf("[%s] %s", req.ID, err) 328 | } 329 | 330 | case strings.Contains(req.ContentType, "x-www-form-urlencoded"): 331 | err = req.ParseFormPayload() 332 | if err != nil { 333 | log.Printf("[%s] %s", req.ID, err) 334 | } 335 | 336 | case strings.Contains(req.ContentType, "xml"): 337 | err = req.ParseXMLPayload() 338 | if err != nil { 339 | log.Printf("[%s] %s", req.ID, err) 340 | } 341 | 342 | case isMultipart: 343 | err = r.ParseMultipartForm(appFlags.MaxMultipartMem) 344 | if err != nil { 345 | msg := fmt.Sprintf("[%s] error parsing multipart form: %+v\n", req.ID, err) 346 | log.Println(msg) 347 | w.WriteHeader(http.StatusInternalServerError) 348 | fmt.Fprint(w, "Error occurred while parsing multipart form.") 349 | return 350 | } 351 | 352 | for k, v := range r.MultipartForm.Value { 353 | log.Printf("[%s] found multipart form value %q", req.ID, k) 354 | 355 | if req.Payload == nil { 356 | req.Payload = make(map[string]interface{}) 357 | } 358 | 359 | // TODO(moorereason): support duplicate, named values 360 | req.Payload[k] = v[0] 361 | } 362 | 363 | for k, v := range r.MultipartForm.File { 364 | // Force parsing as JSON regardless of Content-Type. 365 | var parseAsJSON bool 366 | for _, j := range matchedHook.JSONStringParameters { 367 | if j.Source == "payload" && j.Name == k { 368 | parseAsJSON = true 369 | break 370 | } 371 | } 372 | 373 | // TODO(moorereason): we need to support multiple parts 374 | // with the same name instead of just processing the first 375 | // one. Will need #215 resolved first. 376 | 377 | // MIME encoding can contain duplicate headers, so check them 378 | // all. 379 | if !parseAsJSON && len(v[0].Header["Content-Type"]) > 0 { 380 | for _, j := range v[0].Header["Content-Type"] { 381 | if j == "application/json" { 382 | parseAsJSON = true 383 | break 384 | } 385 | } 386 | } 387 | 388 | if parseAsJSON { 389 | log.Printf("[%s] parsing multipart form file %q as JSON\n", req.ID, k) 390 | 391 | f, err := v[0].Open() 392 | if err != nil { 393 | msg := fmt.Sprintf("[%s] error parsing multipart form file: %+v\n", req.ID, err) 394 | log.Println(msg) 395 | w.WriteHeader(http.StatusInternalServerError) 396 | fmt.Fprint(w, "Error occurred while parsing multipart form file.") 397 | return 398 | } 399 | 400 | decoder := json.NewDecoder(f) 401 | decoder.UseNumber() 402 | 403 | var part map[string]interface{} 404 | err = decoder.Decode(&part) 405 | if err != nil { 406 | log.Printf("[%s] error parsing JSON payload file: %+v\n", req.ID, err) 407 | } 408 | 409 | if req.Payload == nil { 410 | req.Payload = make(map[string]interface{}) 411 | } 412 | req.Payload[k] = part 413 | } 414 | } 415 | 416 | default: 417 | log.Printf("[%s] error parsing body payload due to unsupported content type header: %s\n", req.ID, req.ContentType) 418 | } 419 | 420 | // handle hook 421 | errors := matchedHook.ParseJSONParameters(req) 422 | for _, err := range errors { 423 | log.Printf("[%s] error parsing JSON parameters: %s\n", req.ID, err) 424 | } 425 | 426 | var ok bool 427 | 428 | if matchedHook.TriggerRule == nil { 429 | ok = true 430 | } else { 431 | // Save signature soft failures option in request for evaluators 432 | req.AllowSignatureErrors = matchedHook.TriggerSignatureSoftFailures 433 | 434 | ok, err = matchedHook.TriggerRule.Evaluate(req) 435 | if err != nil { 436 | if !hook.IsParameterNodeError(err) { 437 | msg := fmt.Sprintf("[%s] error evaluating hook: %s", req.ID, err) 438 | log.Println(msg) 439 | w.WriteHeader(http.StatusInternalServerError) 440 | fmt.Fprint(w, "Error occurred while evaluating hook rules.") 441 | return 442 | } 443 | 444 | log.Printf("[%s] %v", req.ID, err) 445 | } 446 | } 447 | 448 | if ok { 449 | log.Printf("[%s] %s hook triggered successfully\n", req.ID, matchedHook.ID) 450 | 451 | for _, responseHeader := range matchedHook.ResponseHeaders { 452 | w.Header().Set(responseHeader.Name, responseHeader.Value) 453 | } 454 | 455 | if matchedHook.StreamCommandOutput { 456 | _, err := handleHook(matchedHook, req, w) 457 | if err != nil { 458 | fmt.Fprint(w, "Error occurred while executing the hook's stream command. Please check your logs for more details.") 459 | } 460 | } else if matchedHook.CaptureCommandOutput { 461 | response, err := handleHook(matchedHook, req, nil) 462 | 463 | if err != nil { 464 | w.WriteHeader(http.StatusInternalServerError) 465 | if matchedHook.CaptureCommandOutputOnError { 466 | fmt.Fprint(w, response) 467 | } else { 468 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 469 | fmt.Fprint(w, "Error occurred while executing the hook's command. Please check your logs for more details.") 470 | } 471 | } else { 472 | // Check if a success return code is configured for the hook 473 | if matchedHook.SuccessHttpResponseCode != 0 { 474 | writeHttpResponseCode(w, req.ID, matchedHook.ID, matchedHook.SuccessHttpResponseCode) 475 | } 476 | fmt.Fprint(w, response) 477 | } 478 | } else { 479 | go handleHook(matchedHook, req, nil) 480 | 481 | // Check if a success return code is configured for the hook 482 | if matchedHook.SuccessHttpResponseCode != 0 { 483 | writeHttpResponseCode(w, req.ID, matchedHook.ID, matchedHook.SuccessHttpResponseCode) 484 | } 485 | 486 | fmt.Fprint(w, matchedHook.ResponseMessage) 487 | } 488 | return 489 | } 490 | 491 | // Check if a return code is configured for the hook 492 | if matchedHook.TriggerRuleMismatchHttpResponseCode != 0 { 493 | writeHttpResponseCode(w, req.ID, matchedHook.ID, matchedHook.TriggerRuleMismatchHttpResponseCode) 494 | } 495 | 496 | // if none of the hooks got triggered 497 | log.Printf("[%s] %s got matched, but didn't get triggered because the trigger rules were not satisfied\n", req.ID, matchedHook.ID) 498 | 499 | fmt.Fprint(w, "Hook rules were not satisfied.") 500 | } 501 | } 502 | 503 | func handleHook(h *hook.Hook, r *hook.Request, w http.ResponseWriter) (string, error) { 504 | var errors []error 505 | 506 | // check the command exists 507 | var lookpath string 508 | if filepath.IsAbs(h.ExecuteCommand) || h.CommandWorkingDirectory == "" { 509 | lookpath = h.ExecuteCommand 510 | } else { 511 | lookpath = filepath.Join(h.CommandWorkingDirectory, h.ExecuteCommand) 512 | } 513 | 514 | cmdPath, err := exec.LookPath(lookpath) 515 | if err != nil { 516 | log.Printf("[%s] error in %s", r.ID, err) 517 | 518 | // check if parameters specified in execute-command by mistake 519 | if strings.IndexByte(h.ExecuteCommand, ' ') != -1 { 520 | s := strings.Fields(h.ExecuteCommand)[0] 521 | log.Printf("[%s] use 'pass-arguments-to-command' to specify args for '%s'", r.ID, s) 522 | } 523 | 524 | return "", err 525 | } 526 | 527 | cmd := exec.Command(cmdPath) 528 | cmd.Dir = h.CommandWorkingDirectory 529 | 530 | cmd.Args, errors = h.ExtractCommandArguments(r) 531 | for _, err := range errors { 532 | log.Printf("[%s] error extracting command arguments: %s\n", r.ID, err) 533 | } 534 | 535 | var envs []string 536 | envs, errors = h.ExtractCommandArgumentsForEnv(r) 537 | 538 | for _, err := range errors { 539 | log.Printf("[%s] error extracting command arguments for environment: %s\n", r.ID, err) 540 | } 541 | 542 | files, errors := h.ExtractCommandArgumentsForFile(r) 543 | 544 | for _, err := range errors { 545 | log.Printf("[%s] error extracting command arguments for file: %s\n", r.ID, err) 546 | } 547 | 548 | for i := range files { 549 | tmpfile, err := os.CreateTemp(h.CommandWorkingDirectory, files[i].EnvName) 550 | if err != nil { 551 | log.Printf("[%s] error creating temp file [%s]", r.ID, err) 552 | continue 553 | } 554 | log.Printf("[%s] writing env %s file %s", r.ID, files[i].EnvName, tmpfile.Name()) 555 | if _, err := tmpfile.Write(files[i].Data); err != nil { 556 | log.Printf("[%s] error writing file %s [%s]", r.ID, tmpfile.Name(), err) 557 | continue 558 | } 559 | if err := tmpfile.Close(); err != nil { 560 | log.Printf("[%s] error closing file %s [%s]", r.ID, tmpfile.Name(), err) 561 | continue 562 | } 563 | 564 | files[i].File = tmpfile 565 | envs = append(envs, files[i].EnvName+"="+tmpfile.Name()) 566 | } 567 | 568 | cmd.Env = append(os.Environ(), envs...) 569 | 570 | log.Printf("[%s] executing %s (%s) with arguments %q and environment %s using %s as cwd\n", r.ID, h.ExecuteCommand, cmd.Path, cmd.Args, envs, cmd.Dir) 571 | 572 | var out []byte 573 | if w != nil { 574 | log.Printf("[%s] command output will be streamed to response", r.ID) 575 | 576 | // Implementation from https://play.golang.org/p/PpbPyXbtEs 577 | // as described in https://stackoverflow.com/questions/19292113/not-buffered-http-responsewritter-in-golang 578 | fw := flushWriter{w: w} 579 | if f, ok := w.(http.Flusher); ok { 580 | fw.f = f 581 | } 582 | cmd.Stderr = &fw 583 | cmd.Stdout = &fw 584 | 585 | if err := cmd.Run(); err != nil { 586 | log.Printf("[%s] error occurred: %+v\n", r.ID, err) 587 | } 588 | } else { 589 | out, err = cmd.CombinedOutput() 590 | 591 | log.Printf("[%s] command output: %s\n", r.ID, out) 592 | 593 | if err != nil { 594 | log.Printf("[%s] error occurred: %+v\n", r.ID, err) 595 | } 596 | } 597 | 598 | for i := range files { 599 | if files[i].File != nil { 600 | log.Printf("[%s] removing file %s\n", r.ID, files[i].File.Name()) 601 | err := os.Remove(files[i].File.Name()) 602 | if err != nil { 603 | log.Printf("[%s] error removing file %s [%s]", r.ID, files[i].File.Name(), err) 604 | } 605 | } 606 | } 607 | 608 | log.Printf("[%s] finished handling %s\n", r.ID, h.ID) 609 | 610 | return string(out), err 611 | } 612 | 613 | func writeHttpResponseCode(w http.ResponseWriter, rid, hookId string, responseCode int) { 614 | // Check if the given return code is supported by the http package 615 | // by testing if there is a StatusText for this code. 616 | if len(http.StatusText(responseCode)) > 0 { 617 | w.WriteHeader(responseCode) 618 | } else { 619 | log.Printf("[%s] %s got matched, but the configured return code %d is unknown - defaulting to 200\n", rid, hookId, responseCode) 620 | } 621 | } 622 | --------------------------------------------------------------------------------