1
0
Fork 0
mirror of https://github.com/adnanh/webhook.git synced 2026-07-25 03:08:41 +00:00
This commit is contained in:
jason.liao 2026-06-02 07:40:37 +00:00 committed by GitHub
commit de18670c91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 5759 additions and 74 deletions

28
.github/release.yml vendored Normal file
View file

@ -0,0 +1,28 @@
changelog:
exclude:
labels:
- ignore-for-release
authors:
- github-actions
categories:
- title: New Features
labels:
- feature
- enhancement
- feat
- title: Bug Fixes
labels:
- bug
- fix
- title: Documentation
labels:
- documentation
- docs
- title: Maintenance
labels:
- chore
- dependencies
- refactor
- title: Other Changes
labels:
- "*"

140
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,140 @@
name: release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
version:
description: "Release version, for example v2.8.4"
required: true
type: string
permissions:
contents: write
jobs:
test:
env:
GOTOOLCHAIN: local
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22.x"
- name: Test
run: go test -v ./...
build:
env:
CGO_ENABLED: "0"
GOTOOLCHAIN: local
needs: test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
- goos: freebsd
goarch: 386
- goos: freebsd
goarch: amd64
- goos: freebsd
goarch: arm64
- goos: linux
goarch: 386
- goos: linux
goarch: amd64
- goos: linux
goarch: arm
- goos: linux
goarch: arm64
- goos: openbsd
goarch: 386
- goos: openbsd
goarch: amd64
- goos: openbsd
goarch: arm64
- goos: windows
goarch: 386
- goos: windows
goarch: amd64
- goos: windows
goarch: arm64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22.x"
- name: Resolve version
id: version
shell: bash
run: |
version="${{ github.event.inputs.version || github.ref_name }}"
version="${version#v}"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Build archive
shell: bash
run: |
target="webhook-${{ matrix.goos }}-${{ matrix.goarch }}"
mkdir -p "dist/$target"
binary="webhook"
if [ "${{ matrix.goos }}" = "windows" ]; then
binary="webhook.exe"
fi
GOOS="${{ matrix.goos }}" GOARCH="${{ matrix.goarch }}" \
go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.version }}" \
-o "dist/$target/$binary" .
cp LICENSE README.md "dist/$target/"
tar -czf "dist/$target.tar.gz" -C dist "$target"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: webhook-${{ matrix.goos }}-${{ matrix.goarch }}
path: dist/*.tar.gz
if-no-files-found: error
publish:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Generate checksums
shell: bash
run: |
cd dist
sha256sum *.tar.gz > checksums.txt
- name: Resolve tag
id: tag
shell: bash
run: |
tag="${{ github.event.inputs.version || github.ref_name }}"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Publish GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: ${{ steps.tag.outputs.tag }}
files: |
dist/*.tar.gz
dist/checksums.txt
generate_release_notes: true

3
.gitignore vendored
View file

@ -4,3 +4,6 @@ coverage
webhook
/test/hookecho
build
.gocache/
.DS_Store

View file

@ -2,7 +2,9 @@
<img src="https://github.com/adnanh/webhook/raw/development/docs/logo/logo-128x128.png" alt="Webhook" align="left" />
[webhook][w] is a lightweight configurable tool written in Go, that allows you to easily create HTTP endpoints (hooks) on your server, which you can use to execute configured commands. You can also pass data from the HTTP request (such as headers, payload or query variables) to your commands. [webhook][w] also allows you to specify rules which have to be satisfied in order for the hook to be triggered.
[webhook][w] is a lightweight configurable tool written in Go, that allows you to easily create HTTP endpoints (hooks) on your server, which you can use to execute configured commands. You can also pass data from the HTTP request (such as headers, payload or query variables) to your commands. [webhook][w] also allows you to specify rules which have to be satisfied in order for the hook to be triggered.
Chinese documentation with the fork-specific install, Admin UI, release, and operations guide is available in [中文说明](README_CN.md).
For example, if you're using Github or Bitbucket, you can use [webhook][w] to set up a hook that runs a redeploy script for your project on your staging server, whenever you push changes to the master branch of your project.
@ -46,7 +48,22 @@ If you are using Debian linux ("stretch" or later), you can install webhook usin
If you are using FreeBSD, you can install webhook using `pkg install webhook`.
### Download prebuilt binaries
Prebuilt binaries for different architectures are available at [GitHub Releases](https://github.com/adnanh/webhook/releases).
Prebuilt binaries for different architectures are available at [GitHub Releases](https://github.com/xtulnx/webhook/releases).
### One-line install or update
The install scripts download the latest GitHub Release asset for your platform and replace the local binary.
Linux, macOS, FreeBSD, or OpenBSD:
```bash
curl -fsSL https://raw.githubusercontent.com/xtulnx/webhook/master/scripts/install.sh | sh
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/xtulnx/webhook/master/scripts/install.ps1 | iex
```
Set `WEBHOOK_VERSION` to install a specific release tag, `WEBHOOK_REPO` to install from a fork, or `WEBHOOK_INSTALL_DIR` to choose the destination directory.
## Configuration
Next step is to define some hooks you want [webhook][w] to serve.
@ -101,6 +118,8 @@ All files are ignored unless they match one of the following criteria:
In either case, the given file part will be parsed as JSON and added to the `payload` map.
If you want a hook command to inspect uploaded files directly, enable the `keep-file-environment` hook option. During command execution webhook will expose each uploaded file as `HOOK_FILE_<FIELD>` and the original filename as `HOOK_FILENAME_<FIELD>`, then remove the temporary file once the command exits. Because multipart field names are copied into the environment variable name after uppercasing, prefer names made of letters, numbers, and underscores if the command will read them from a shell script.
## Templates
[webhook][w] can parse the hooks configuration file as a Go template when given the `-template` [CLI parameter](docs/Webhook-Parameters.md). See the [Templates page](docs/Templates.md) for more details on template usage.

485
README_CN.md Normal file
View file

@ -0,0 +1,485 @@
# webhook 中文使用指南
`webhook` 是一个用 Go 编写的轻量级 HTTP 回调服务。它可以把请求中的 header、query、payload、文件等数据映射到命令参数或环境变量并在规则校验通过后执行指定命令。
本仓库是从 `adnanh/webhook` fork 而来的增强版本,仓库地址为 [xtulnx/webhook](https://github.com/xtulnx/webhook)。除了上游原有能力外,本版本补充了 YAML 总配置、Admin 管理界面、命令超时、并发限制、反向代理真实 IP、PID 文件、一键安装脚本和 GitHub Release 编译发布流程。
![Admin 登录界面](images/img01.webp)
## 主要特性
- 支持 JSON/YAML hook 配置。
- 支持 `-c``-config``--config` 加载 YAML 总配置文件。
- 支持 Admin UI 在线管理已加载的 hooks 文件。
- Admin 登录使用 TOTP 动态验证码,登录后使用短期 JWT 会话。
- 支持全局和单 hook 的命令执行超时。
- 支持全局和单 hook 的并发执行限制。
- 支持反向代理后的真实客户端 IP 提取,适配 `ip-whitelist`
- 支持 multipart 上传文件以临时环境变量形式传给命令。
- 支持 PID 文件、日志文件、热重载、自定义响应头、TLS、Unix socket。
- 提供 Linux/macOS/BSD 的 shell 一键安装脚本和 Windows PowerShell 一键安装脚本。
- 提供 GitHub Actions 自动编译、打包、校验和发布脚本。
## 快速安装或更新
Linux、macOS、FreeBSD、OpenBSD:
```bash
curl -fsSL https://raw.githubusercontent.com/xtulnx/webhook/master/scripts/install.sh | sh
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/xtulnx/webhook/master/scripts/install.ps1 | iex
```
安装脚本默认安装最新 GitHub Release并进行 SHA256 校验。再次执行同一条命令即可更新到最新版本。
可通过环境变量调整安装行为:
```bash
curl -fsSL https://raw.githubusercontent.com/xtulnx/webhook/master/scripts/install.sh | \
WEBHOOK_VERSION=v2.8.4 WEBHOOK_INSTALL_DIR="$HOME/.local/bin" sh
```
PowerShell 示例:
```powershell
$env:WEBHOOK_VERSION = "v2.8.4"
$env:WEBHOOK_INSTALL_DIR = "$env:USERPROFILE\bin"
irm https://raw.githubusercontent.com/xtulnx/webhook/master/scripts/install.ps1 | iex
```
常用变量:
- `WEBHOOK_REPO`: GitHub 仓库,默认 `xtulnx/webhook`
- `WEBHOOK_VERSION`: 版本 tag默认 `latest`
- `WEBHOOK_INSTALL_DIR`: 安装目录。Unix 默认 `/usr/local/bin`Windows 默认 `%LOCALAPPDATA%\Programs\webhook`
- `WEBHOOK_BINARY_NAME`: 安装后的 binary 名称,默认 `webhook``webhook.exe`
## 从源码构建
需要 Go 1.21 或更新版本。
```bash
git clone https://github.com/xtulnx/webhook.git
cd webhook
go build
./webhook -version
```
运行测试:
```bash
go test ./...
```
生成本地 release 包:
```bash
make release
make release-windows
```
## 最小可用示例
创建 hook 文件 `hooks.yaml`
```yaml
- id: deploy
execute-command: /usr/local/bin/deploy.sh
command-working-directory: /srv/app
http-methods:
- POST
response-message: Deploy triggered
trigger-rule:
match:
type: value
value: my-secret
parameter:
source: header
name: X-Webhook-Secret
```
启动服务:
```bash
webhook -hooks hooks.yaml -verbose
```
触发 hook
```bash
curl -X POST \
-H "X-Webhook-Secret: my-secret" \
http://127.0.0.1:9000/hooks/deploy
```
默认监听地址是 `0.0.0.0:9000`,默认 hook URL 前缀是 `/hooks/`
## 使用 YAML 总配置文件
本 fork 新增了总配置文件能力。可以把命令行参数写进 YAML 文件,再用 `-c``-config``--config` 启动。
示例 `webhook.yaml`
```yaml
ip: 0.0.0.0
port: 1987
urlprefix: wh
hooks:
- /etc/webhook/admin.yaml
- /etc/webhook/hooks.yaml
hotreload: true
nopanic: true
verbose: true
logfile: /var/log/webhook.log
pidfile: /var/run/webhook.pid
command-timeout: 30s
max-concurrency: 4
```
启动:
```bash
webhook -c /etc/webhook/webhook.yaml
```
命令行参数优先级高于配置文件。例如:
```bash
webhook -c /etc/webhook/webhook.yaml -port 9000 -debug
```
完整示例可参考 [webhook.yaml.example](webhook.yaml.example)。
## Admin 管理界面
本 fork 内置 Admin UI 和 API可在线查看、新增、编辑和删除已加载的 hooks 文件。
![Hook 结构化编辑器](images/img02.webp)
启用方式:
```yaml
admin: true
admin-path: admin
admin-totp-secret: "BASE32_TOTP_SECRET"
admin-jwt-secret: "replace-with-a-long-random-secret"
admin-session-ttl: 12h
```
启动后访问:
```text
http://127.0.0.1:1987/admin/
```
生成 TOTP Base32 密钥:
```bash
python3 -c 'import base64, os; print(base64.b32encode(os.urandom(20)).decode().rstrip("="))'
```
把生成的密钥配置到 Google Authenticator、Microsoft Authenticator、1Password 等认证器中,然后在 Admin 登录页输入当前 6 位动态码。
注意事项:
- `admin-path` 不能和 `urlprefix` 相同。
- `admin-totp-secret` 必须是 Base32 编码。
- `admin-jwt-secret` 必须设置为足够长的随机字符串。
- 如果启用了 `template: true`Admin 写入会进入只读模式,避免覆盖模板配置。
- 生产环境建议放在 HTTPS 或受保护的反向代理后面。
![参数、规则与 JSON 预览](images/img03.webp)
## Hook 配置要点
Hook 至少需要 `id``execute-command`
```yaml
- id: example
execute-command: /usr/local/bin/example.sh
```
常用字段:
- `command-working-directory`: 命令工作目录。
- `pass-arguments-to-command`: 把请求值作为命令参数传入。
- `pass-environment-to-command`: 把请求值作为环境变量传入。
- `pass-file-to-command`: 把文件参数传入命令。
- `parse-parameters-as-json`: 把指定参数按 JSON 解析。
- `trigger-rule`: 触发规则,支持 `and``or``not``match`
- `response-message`: 成功响应文本。
- `success-http-response-code`: 成功响应状态码。
- `trigger-rule-mismatch-http-response-code`: 规则不匹配时的状态码。
- `include-command-output-in-response`: 把命令输出写入响应。
- `include-command-output-in-response-on-error`: 命令失败时也返回命令输出。
- `command-timeout`: 覆盖全局命令超时。
- `max-concurrency`: 覆盖全局并发限制。
- `keep-file-environment`: 暴露上传文件临时路径给命令。
更完整的字段说明请参考 [docs/Hook-Definition.md](docs/Hook-Definition.md)。
## 命令超时与并发限制
全局命令超时:
```bash
webhook -hooks hooks.yaml -command-timeout 30s
```
单个 hook 覆盖:
```yaml
- id: slow-task
execute-command: /usr/local/bin/slow-task.sh
command-timeout: 2m
```
`0` 表示禁用超时:
```yaml
- id: no-timeout-task
execute-command: /usr/local/bin/task.sh
command-timeout: "0"
```
全局并发限制:
```bash
webhook -hooks hooks.yaml -max-concurrency 2
```
单个 hook 覆盖:
```yaml
- id: deploy
execute-command: /usr/local/bin/deploy.sh
max-concurrency: 1
```
当并发达到上限时,请求会返回 `503 Service Unavailable`
## 反向代理与真实 IP
如果 webhook 部署在 Nginx、Traefik、Caddy 等反向代理后面,直接使用 `RemoteAddr` 会拿到代理 IP而不是真实客户端 IP。本 fork 支持可信代理配置,配合 `ip-whitelist` 使用。
配置示例:
```yaml
real-ip-header: X-Real-Ip
trusted-proxies: "127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
```
Nginx 示例:
```nginx
location / {
proxy_pass http://127.0.0.1:1987;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
只有当请求来源属于 `trusted-proxies`webhook 才会信任 `real-ip-header`,防止客户端伪造 IP header 绕过白名单。
## 上传文件处理
multipart 表单中,符合条件的 JSON 文件或被 `parse-parameters-as-json` 指定的文件会被解析到 payload。
如果命令需要直接读取上传文件,可启用:
```yaml
- id: upload
execute-command: /usr/local/bin/handle-upload.sh
keep-file-environment: true
```
字段名为 `pkg` 的上传文件会在命令运行期间暴露为:
```text
HOOK_FILE_PKG=/tmp/...
HOOK_FILENAME_PKG=original-file-name.tar.gz
```
命令结束后临时文件会被清理。建议 multipart 字段名只使用字母、数字和下划线,方便 shell 脚本读取。
## HTTPS、日志和 PID 文件
HTTPS
```bash
webhook -hooks hooks.yaml -secure -cert /etc/webhook/cert.pem -key /etc/webhook/key.pem
```
指定 TLS 最低版本:
```bash
webhook -hooks hooks.yaml -secure -tls-min-version 1.2
```
日志文件:
```bash
webhook -hooks hooks.yaml -verbose -logfile /var/log/webhook.log
```
PID 文件:
```bash
webhook -hooks hooks.yaml -pidfile /var/run/webhook.pid
```
热重载:
```bash
webhook -hooks hooks.yaml -hotreload
```
## systemd 示例
`/etc/systemd/system/webhook.service`
```ini
[Unit]
Description=Webhook service
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/webhook -c /etc/webhook/webhook.yaml
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
启用并启动:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now webhook
sudo systemctl status webhook
```
## GitHub 编译发布流程
本仓库新增 [release workflow](.github/workflows/release.yml)。当推送 `v*` tag 时,会自动:
1. 运行测试。
2. 为 Linux、macOS、FreeBSD、OpenBSD、Windows 构建多架构 binary。
3. 打包为 `webhook-{os}-{arch}.tar.gz`
4. 生成 `checksums.txt`
5. 发布到 GitHub Releases。
发布新版本:
```bash
git tag v2.8.4
git push origin v2.8.4
```
也可以在 GitHub Actions 页面手动运行 `release` workflow并填写版本号。
### 版本维护建议
建议按语义化版本维护 tag
- `v2.8.4`: 修复 bug、文档补充、兼容性小改动。
- `v2.9.0`: 新增功能,但不破坏已有用法。
- `v3.0.0`: 有破坏性变更,需要用户调整配置或调用方式。
发布前建议创建一个专门的版本提交,把版本号、文档和发布相关改动放在一起:
```bash
git add webhook.go README.md README_CN.md .github scripts
git commit -m "release: prepare v2.8.4"
git tag v2.8.4
git push origin master
git push origin v2.8.4
```
Release Notes 由 GitHub 根据上一个 tag 到当前 tag 之间的 commit/PR 自动生成。为了让版本说明更清楚commit 标题建议使用类似 Conventional Commits 的格式:
```text
feat: add admin hook editor
fix: correct reverse proxy ip whitelist handling
docs: add Chinese operations guide
chore: update release workflow
```
如果通过 Pull Request 合并,建议给 PR 打 label例如 `feature``fix``documentation``chore`。仓库中的 `.github/release.yml` 会按这些 label 对自动 Release Notes 分类。
发布前建议确认:
- `webhook.go` 中默认版本号已更新,或确认 workflow 注入的 tag 版本符合预期。
- GitHub Actions 对仓库有 `contents: write` 权限。
- 目标 tag 不要重复使用,除非你明确要覆盖 Release。
## 常见问题
### 一键安装下载失败
检查 GitHub Release 是否已经存在对应平台资产。例如 Linux amd64 需要:
```text
webhook-linux-amd64.tar.gz
```
也可以指定仓库和版本:
```bash
curl -fsSL https://raw.githubusercontent.com/xtulnx/webhook/master/scripts/install.sh | \
WEBHOOK_REPO=xtulnx/webhook WEBHOOK_VERSION=v2.8.4 sh
```
### Admin 无法登录
优先检查:
- `admin: true` 是否启用。
- `admin-totp-secret` 是否为 Base32。
- 认证器时间是否准确。
- `admin-jwt-secret` 是否为空。
- 请求路径是否为配置的 `admin-path`
### IP 白名单在反向代理后不生效
需要同时配置:
- 反向代理写入 `X-Real-IP``X-Forwarded-For`
- webhook 设置 `real-ip-header`
- webhook 设置 `trusted-proxies`,并包含代理服务器 IP。
### Hook 文件无法通过 Admin 修改
如果使用 `template: true`Admin 写入会被禁用。请关闭模板模式,或手动编辑模板源文件。
## 目录说明
- `webhook.go`: 主入口和 CLI 参数。
- `config.go`: YAML 总配置加载。
- `admin.go``admin_store.go``admin_ui.go`: Admin UI/API 和配置写入。
- `execution.go`: 命令超时和并发控制。
- `realip.go`: 反向代理真实 IP 处理。
- `internal/hook/`: hook 解析、规则判断和请求映射。
- `internal/middleware/`: HTTP 日志和请求辅助中间件。
- `internal/pidfile/`: PID 文件处理。
- `adminui/`: 内嵌 Admin 前端页面。
- `scripts/`: 一键安装脚本。
- `.github/workflows/`: GitHub Actions 构建和发布脚本。
## 安全建议
- 不要提交真实密钥、生产 hook 定义或 TOTP/JWT secret。
- Admin UI 建议放在 HTTPS 后面,并限制访问来源。
- `trusted-proxies` 只填写你真正控制的代理地址。
- `include-command-output-in-response` 可能泄露命令输出,生产环境谨慎开启。
- hook 执行的脚本要自行做好权限、参数校验和日志记录。
## 许可证
本项目沿用上游 MIT License。详见 [LICENSE](LICENSE)。

479
admin.go Normal file
View file

@ -0,0 +1,479 @@
package main
import (
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"encoding/base32"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/adnanh/webhook/internal/hook"
"github.com/gorilla/mux"
)
var (
adminEnabled = flag.Bool("admin", false, "enable the admin UI and API for managing hooks")
adminURLPrefix = flag.String("admin-path", "admin", "url prefix to use for the admin UI and API")
adminTOTPSecret = flag.String("admin-totp-secret", "", "base32 encoded TOTP secret for admin login")
adminJWTSecret = flag.String("admin-jwt-secret", "", "secret used to sign admin JWT sessions")
adminSessionTTL = flag.String("admin-session-ttl", "12h", "lifetime of an admin JWT session")
)
const adminTokenCookieName = "webhook_admin_token"
type adminAuthConfig struct {
basePath string
totpSecret []byte
jwtSecret []byte
sessionTTL time.Duration
}
type adminJWTHeader struct {
Algorithm string `json:"alg"`
Type string `json:"typ"`
}
type adminJWTClaims struct {
Subject string `json:"sub"`
IssuedAt int64 `json:"iat"`
NotBefore int64 `json:"nbf"`
ExpiresAt int64 `json:"exp"`
}
type adminLoginRequest struct {
Code string `json:"code"`
}
type adminHookMutationRequest struct {
File string `json:"file"`
CurrentID string `json:"currentId,omitempty"`
Hook hook.Hook `json:"hook"`
}
var currentAdminAuth *adminAuthConfig
func initAdmin() error {
if !*adminEnabled {
return nil
}
trimmedAdminPath := strings.Trim(strings.TrimSpace(*adminURLPrefix), "/")
if trimmedAdminPath == "" {
return errors.New("admin-path must not be empty")
}
trimmedHooksPath := strings.Trim(strings.TrimSpace(*hooksURLPrefix), "/")
if trimmedHooksPath == trimmedAdminPath {
return errors.New("admin-path must not match urlprefix")
}
ttl, err := time.ParseDuration(strings.TrimSpace(*adminSessionTTL))
if err != nil {
return fmt.Errorf("invalid admin-session-ttl: %w", err)
}
if ttl <= 0 {
return errors.New("admin-session-ttl must be greater than zero")
}
totpSecret, err := decodeTOTPSecret(*adminTOTPSecret)
if err != nil {
return fmt.Errorf("invalid admin-totp-secret: %w", err)
}
jwtSecret := strings.TrimSpace(*adminJWTSecret)
if jwtSecret == "" {
return errors.New("admin-jwt-secret must not be empty")
}
*adminURLPrefix = trimmedAdminPath
currentAdminAuth = &adminAuthConfig{
basePath: makeBaseURL(adminURLPrefix),
totpSecret: totpSecret,
jwtSecret: []byte(jwtSecret),
sessionTTL: ttl,
}
return nil
}
func registerAdminRoutes(r *mux.Router) {
if !*adminEnabled {
return
}
basePath := currentAdminAuth.basePath
staticHandler := http.StripPrefix(basePath+"/", adminStaticHandler())
r.HandleFunc(basePath, adminUIHandler).Methods(http.MethodGet)
r.HandleFunc(basePath+"/", adminUIHandler).Methods(http.MethodGet)
r.HandleFunc(basePath+"/api/auth/login", adminLoginHandler).Methods(http.MethodPost)
r.HandleFunc(basePath+"/api/auth/logout", adminLogoutHandler).Methods(http.MethodPost)
r.HandleFunc(basePath+"/api/config", adminRequireAuth(adminConfigHandler)).Methods(http.MethodGet)
r.HandleFunc(basePath+"/api/hooks", adminRequireAuth(adminCreateHookHandler)).Methods(http.MethodPost)
r.HandleFunc(basePath+"/api/hooks", adminRequireAuth(adminUpdateHookHandler)).Methods(http.MethodPut)
r.HandleFunc(basePath+"/api/hooks", adminRequireAuth(adminDeleteHookHandler)).Methods(http.MethodDelete)
r.PathPrefix(basePath + "/assets/").Handler(staticHandler).Methods(http.MethodGet)
}
func decodeTOTPSecret(secret string) ([]byte, error) {
normalized := strings.ToUpper(strings.TrimSpace(secret))
replacer := strings.NewReplacer(" ", "", "-", "", "\n", "", "\r", "", "\t", "", "=", "")
normalized = replacer.Replace(normalized)
if normalized == "" {
return nil, errors.New("secret is empty")
}
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(normalized)
if err == nil {
return decoded, nil
}
if rem := len(normalized) % 8; rem != 0 {
normalized += strings.Repeat("=", 8-rem)
}
return base32.StdEncoding.DecodeString(normalized)
}
func hotpCode(secret []byte, counter uint64, digits int) string {
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], counter)
mac := hmac.New(sha1.New, secret)
_, _ = mac.Write(buf[:])
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0x0f
value := (int(sum[offset])&0x7f)<<24 |
int(sum[offset+1])<<16 |
int(sum[offset+2])<<8 |
int(sum[offset+3])
mod := 1
for i := 0; i < digits; i++ {
mod *= 10
}
code := value % mod
format := "%0" + strconv.Itoa(digits) + "d"
return fmt.Sprintf(format, code)
}
func verifyTOTP(secret []byte, code string, now time.Time) bool {
code = strings.TrimSpace(code)
if len(code) != 6 {
return false
}
for _, ch := range code {
if ch < '0' || ch > '9' {
return false
}
}
counter := now.Unix() / 30
for offset := int64(-1); offset <= 1; offset++ {
current := counter + offset
if current < 0 {
continue
}
expected := hotpCode(secret, uint64(current), 6)
if hmac.Equal([]byte(expected), []byte(code)) {
return true
}
}
return false
}
func signAdminJWT(now time.Time) (string, error) {
if currentAdminAuth == nil {
return "", errors.New("admin auth is not initialized")
}
header := adminJWTHeader{
Algorithm: "HS256",
Type: "JWT",
}
claims := adminJWTClaims{
Subject: "webhook-admin",
IssuedAt: now.Unix(),
NotBefore: now.Add(-30 * time.Second).Unix(),
ExpiresAt: now.Add(currentAdminAuth.sessionTTL).Unix(),
}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", err
}
claimsJSON, err := json.Marshal(claims)
if err != nil {
return "", err
}
payload := base64.RawURLEncoding.EncodeToString(headerJSON) + "." + base64.RawURLEncoding.EncodeToString(claimsJSON)
mac := hmac.New(sha256.New, currentAdminAuth.jwtSecret)
_, _ = mac.Write([]byte(payload))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return payload + "." + signature, nil
}
func parseAdminJWT(token string, now time.Time) (*adminJWTClaims, error) {
if currentAdminAuth == nil {
return nil, errors.New("admin auth is not initialized")
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, errors.New("invalid token format")
}
signed := parts[0] + "." + parts[1]
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, errors.New("invalid token signature encoding")
}
mac := hmac.New(sha256.New, currentAdminAuth.jwtSecret)
_, _ = mac.Write([]byte(signed))
expected := mac.Sum(nil)
if !hmac.Equal(signature, expected) {
return nil, errors.New("invalid token signature")
}
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, errors.New("invalid token header encoding")
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, errors.New("invalid token payload encoding")
}
var header adminJWTHeader
if err := json.Unmarshal(headerBytes, &header); err != nil {
return nil, errors.New("invalid token header")
}
if header.Algorithm != "HS256" || header.Type != "JWT" {
return nil, errors.New("unsupported token header")
}
var claims adminJWTClaims
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
return nil, errors.New("invalid token payload")
}
if claims.Subject != "webhook-admin" {
return nil, errors.New("invalid token subject")
}
nowUnix := now.Unix()
if claims.NotBefore != 0 && nowUnix < claims.NotBefore {
return nil, errors.New("token is not active yet")
}
if claims.ExpiresAt == 0 || nowUnix >= claims.ExpiresAt {
return nil, errors.New("token has expired")
}
return &claims, nil
}
func setAdminTokenCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: adminTokenCookieName,
Value: token,
Path: currentAdminAuth.basePath,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: *secure,
MaxAge: int(currentAdminAuth.sessionTTL.Seconds()),
})
}
func clearAdminTokenCookie(w http.ResponseWriter) {
path := "/"
if currentAdminAuth != nil {
path = currentAdminAuth.basePath
}
http.SetCookie(w, &http.Cookie{
Name: adminTokenCookieName,
Value: "",
Path: path,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: *secure,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
func adminTokenFromRequest(r *http.Request) string {
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
return strings.TrimSpace(authHeader[7:])
}
cookie, err := r.Cookie(adminTokenCookieName)
if err != nil {
return ""
}
return cookie.Value
}
func authenticateAdminRequest(r *http.Request) error {
token := adminTokenFromRequest(r)
if token == "" {
return errors.New("missing admin token")
}
_, err := parseAdminJWT(token, time.Now())
return err
}
func adminRequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := authenticateAdminRequest(r); err != nil {
writeAdminError(w, http.StatusUnauthorized, "authentication required")
return
}
next(w, r)
}
}
func writeAdminJSON(w http.ResponseWriter, status int, payload interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeAdminError(w http.ResponseWriter, status int, message string) {
writeAdminJSON(w, status, map[string]string{"error": message})
}
func decodeAdminJSON(r *http.Request, payload interface{}) error {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
return decoder.Decode(payload)
}
func adminLoginHandler(w http.ResponseWriter, r *http.Request) {
var loginReq adminLoginRequest
if err := decodeAdminJSON(r, &loginReq); err != nil {
writeAdminError(w, http.StatusBadRequest, "invalid login payload")
return
}
if !verifyTOTP(currentAdminAuth.totpSecret, loginReq.Code, time.Now()) {
log.Printf("admin login failed from %s", r.RemoteAddr)
writeAdminError(w, http.StatusUnauthorized, "invalid TOTP code")
return
}
token, err := signAdminJWT(time.Now())
if err != nil {
writeAdminError(w, http.StatusInternalServerError, "could not create session token")
return
}
setAdminTokenCookie(w, token)
log.Printf("admin login succeeded from %s", r.RemoteAddr)
writeAdminJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func adminLogoutHandler(w http.ResponseWriter, r *http.Request) {
clearAdminTokenCookie(w)
writeAdminJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func adminConfigHandler(w http.ResponseWriter, r *http.Request) {
writeAdminJSON(w, http.StatusOK, hooksStateSnapshot())
}
func adminMutationStatus(err error) int {
switch {
case err == nil:
return http.StatusOK
case errors.Is(err, errAdminReadOnly):
return http.StatusConflict
case errors.Is(err, errUnknownHookFile):
return http.StatusNotFound
case strings.Contains(err.Error(), "was not found"):
return http.StatusNotFound
case strings.Contains(err.Error(), "already defined"):
return http.StatusConflict
default:
return http.StatusBadRequest
}
}
func adminCreateHookHandler(w http.ResponseWriter, r *http.Request) {
var req adminHookMutationRequest
if err := decodeAdminJSON(r, &req); err != nil {
writeAdminError(w, http.StatusBadRequest, "invalid hook payload")
return
}
if err := upsertHookInFile(req.File, "", req.Hook); err != nil {
writeAdminError(w, adminMutationStatus(err), err.Error())
return
}
writeAdminJSON(w, http.StatusCreated, map[string]bool{"ok": true})
}
func adminUpdateHookHandler(w http.ResponseWriter, r *http.Request) {
var req adminHookMutationRequest
if err := decodeAdminJSON(r, &req); err != nil {
writeAdminError(w, http.StatusBadRequest, "invalid hook payload")
return
}
if strings.TrimSpace(req.CurrentID) == "" {
writeAdminError(w, http.StatusBadRequest, "currentId is required")
return
}
if err := upsertHookInFile(req.File, req.CurrentID, req.Hook); err != nil {
writeAdminError(w, adminMutationStatus(err), err.Error())
return
}
writeAdminJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func adminDeleteHookHandler(w http.ResponseWriter, r *http.Request) {
var req adminHookMutationRequest
if err := decodeAdminJSON(r, &req); err != nil {
writeAdminError(w, http.StatusBadRequest, "invalid delete payload")
return
}
if strings.TrimSpace(req.File) == "" {
writeAdminError(w, http.StatusBadRequest, "file is required")
return
}
if strings.TrimSpace(req.CurrentID) == "" {
writeAdminError(w, http.StatusBadRequest, "currentId is required")
return
}
if err := deleteHookFromFile(req.File, req.CurrentID); err != nil {
writeAdminError(w, adminMutationStatus(err), err.Error())
return
}
writeAdminJSON(w, http.StatusOK, map[string]bool{"ok": true})
}

356
admin_store.go Normal file
View file

@ -0,0 +1,356 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/adnanh/webhook/internal/hook"
"github.com/ghodss/yaml"
)
var (
loadedHooksMu sync.RWMutex
errAdminReadOnly = errors.New("admin writes are disabled when -template is enabled")
errUnknownHookFile = errors.New("unknown hooks file")
)
type adminHooksFile struct {
Path string `json:"path"`
Format string `json:"format"`
Writable bool `json:"writable"`
Hooks hook.Hooks `json:"hooks"`
}
type adminHooksState struct {
Files []adminHooksFile `json:"files"`
ReadOnly bool `json:"readOnly"`
ReadOnlyReason string `json:"readOnlyReason,omitempty"`
}
func adminWritesDisabled() bool {
return *asTemplate
}
func adminReadOnlyReason() string {
if !adminWritesDisabled() {
return ""
}
return "editing is disabled while webhook is running with -template because the original template source cannot be preserved safely"
}
func cloneHook(src hook.Hook) hook.Hook {
dst := src
if src.ResponseHeaders != nil {
dst.ResponseHeaders = append(hook.ResponseHeaders(nil), src.ResponseHeaders...)
}
if src.PassEnvironmentToCommand != nil {
dst.PassEnvironmentToCommand = append([]hook.Argument(nil), src.PassEnvironmentToCommand...)
}
if src.PassArgumentsToCommand != nil {
dst.PassArgumentsToCommand = append([]hook.Argument(nil), src.PassArgumentsToCommand...)
}
if src.PassFileToCommand != nil {
dst.PassFileToCommand = append([]hook.Argument(nil), src.PassFileToCommand...)
}
if src.JSONStringParameters != nil {
dst.JSONStringParameters = append([]hook.Argument(nil), src.JSONStringParameters...)
}
if src.HTTPMethods != nil {
dst.HTTPMethods = append([]string(nil), src.HTTPMethods...)
}
if src.MaxConcurrency != nil {
value := *src.MaxConcurrency
dst.MaxConcurrency = &value
}
return dst
}
func cloneHooks(src hook.Hooks) hook.Hooks {
if src == nil {
return nil
}
dst := make(hook.Hooks, len(src))
for i := range src {
dst[i] = cloneHook(src[i])
}
return dst
}
func cloneLoadedHooksMapLocked() map[string]hook.Hooks {
dst := make(map[string]hook.Hooks, len(loadedHooksFromFiles))
for path, hooksInFile := range loadedHooksFromFiles {
dst[path] = cloneHooks(hooksInFile)
}
return dst
}
func hooksFilesSnapshot() []string {
loadedHooksMu.RLock()
defer loadedHooksMu.RUnlock()
return append([]string(nil), []string(hooksFiles)...)
}
func hooksStateSnapshot() adminHooksState {
loadedHooksMu.RLock()
files := append([]string(nil), []string(hooksFiles)...)
loaded := cloneLoadedHooksMapLocked()
loadedHooksMu.RUnlock()
state := adminHooksState{
ReadOnly: adminWritesDisabled(),
ReadOnlyReason: adminReadOnlyReason(),
Files: make([]adminHooksFile, 0, len(files)),
}
for _, path := range files {
hooksInFile := cloneHooks(loaded[path])
if hooksInFile == nil {
hooksInFile = hook.Hooks{}
}
sort.Slice(hooksInFile, func(i, j int) bool {
return hooksInFile[i].ID < hooksInFile[j].ID
})
state.Files = append(state.Files, adminHooksFile{
Path: path,
Format: hooksFileFormat(path),
Writable: !state.ReadOnly,
Hooks: hooksInFile,
})
}
return state
}
func hooksFileFormat(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".yaml", ".yml":
return "yaml"
default:
return "json"
}
}
func marshalHooksFile(path string, hooksInFile hook.Hooks) ([]byte, error) {
var (
data []byte
err error
)
switch hooksFileFormat(path) {
case "yaml":
data, err = yaml.Marshal(hooksInFile)
default:
data, err = json.MarshalIndent(hooksInFile, "", " ")
}
if err != nil {
return nil, err
}
if len(data) == 0 || data[len(data)-1] != '\n' {
data = append(data, '\n')
}
return data, nil
}
func writeHooksFile(path string, hooksInFile hook.Hooks) error {
data, err := marshalHooksFile(path, hooksInFile)
if err != nil {
return err
}
dir := filepath.Dir(path)
if dir == "" {
dir = "."
}
tmp, err := ioutil.TempFile(dir, filepath.Base(path)+".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
mode := os.FileMode(0o644)
if stat, statErr := os.Stat(path); statErr == nil {
mode = stat.Mode()
}
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
renameErr := os.Rename(tmpName, path)
if renameErr == nil {
return nil
}
if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) {
return renameErr
}
return os.Rename(tmpName, path)
}
func validateUniqueHookIDs(candidate map[string]hook.Hooks) error {
seen := make(map[string]string)
for path, hooksInFile := range candidate {
for _, currentHook := range hooksInFile {
id := strings.TrimSpace(currentHook.ID)
if id == "" {
return fmt.Errorf("hook id is required for hooks file %s", path)
}
if prevPath, ok := seen[id]; ok {
return fmt.Errorf("hook with ID %s is already defined in %s", id, prevPath)
}
seen[id] = path
}
}
return nil
}
func hookIndexByID(hooksInFile hook.Hooks, id string) int {
for i := range hooksInFile {
if hooksInFile[i].ID == id {
return i
}
}
return -1
}
func normalizeAdminHook(current hook.Hook) hook.Hook {
current.ID = strings.TrimSpace(current.ID)
current.ExecuteCommand = strings.TrimSpace(current.ExecuteCommand)
current.CommandWorkingDirectory = strings.TrimSpace(current.CommandWorkingDirectory)
current.CommandTimeout = strings.TrimSpace(current.CommandTimeout)
current.ResponseMessage = strings.TrimSpace(current.ResponseMessage)
if len(current.HTTPMethods) != 0 {
methods := make([]string, 0, len(current.HTTPMethods))
for _, method := range current.HTTPMethods {
method = strings.ToUpper(strings.TrimSpace(method))
if method != "" {
methods = append(methods, method)
}
}
current.HTTPMethods = methods
}
return current
}
func upsertHookInFile(path, currentID string, updated hook.Hook) error {
if adminWritesDisabled() {
return errAdminReadOnly
}
updated = normalizeAdminHook(updated)
if updated.ID == "" {
return errors.New("hook id is required")
}
if err := updated.ValidateExecutionSettings(); err != nil {
return err
}
loadedHooksMu.Lock()
defer loadedHooksMu.Unlock()
existingHooks, ok := loadedHooksFromFiles[path]
if !ok {
return fmt.Errorf("%w: %s", errUnknownHookFile, path)
}
nextHooks := cloneHooks(existingHooks)
if currentID == "" {
if hookIndexByID(nextHooks, updated.ID) != -1 {
return fmt.Errorf("hook with ID %s is already defined", updated.ID)
}
nextHooks = append(nextHooks, updated)
} else {
index := hookIndexByID(nextHooks, currentID)
if index == -1 {
return fmt.Errorf("hook with ID %s was not found in %s", currentID, path)
}
nextHooks[index] = updated
}
candidate := cloneLoadedHooksMapLocked()
candidate[path] = nextHooks
if err := validateUniqueHookIDs(candidate); err != nil {
return err
}
if err := writeHooksFile(path, nextHooks); err != nil {
return err
}
loadedHooksFromFiles[path] = nextHooks
return nil
}
func deleteHookFromFile(path, id string) error {
if adminWritesDisabled() {
return errAdminReadOnly
}
loadedHooksMu.Lock()
defer loadedHooksMu.Unlock()
existingHooks, ok := loadedHooksFromFiles[path]
if !ok {
return fmt.Errorf("%w: %s", errUnknownHookFile, path)
}
index := hookIndexByID(existingHooks, id)
if index == -1 {
return fmt.Errorf("hook with ID %s was not found in %s", id, path)
}
nextHooks := cloneHooks(existingHooks[:index])
nextHooks = append(nextHooks, cloneHooks(existingHooks[index+1:])...)
candidate := cloneLoadedHooksMapLocked()
candidate[path] = nextHooks
if err := validateUniqueHookIDs(candidate); err != nil {
return err
}
if err := writeHooksFile(path, nextHooks); err != nil {
return err
}
loadedHooksFromFiles[path] = nextHooks
return nil
}

391
admin_test.go Normal file
View file

@ -0,0 +1,391 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/adnanh/webhook/internal/hook"
"github.com/gorilla/mux"
)
const testAdminBase32Secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
func setupAdminTestState(t *testing.T) func() {
t.Helper()
savedSecure := *secure
savedAsTemplate := *asTemplate
savedAdminEnabled := *adminEnabled
savedAdminAuth := currentAdminAuth
loadedHooksMu.RLock()
savedHooksFiles := append(hook.HooksFiles(nil), hooksFiles...)
savedLoaded := cloneLoadedHooksMapLocked()
loadedHooksMu.RUnlock()
decodedSecret, err := decodeTOTPSecret(testAdminBase32Secret)
if err != nil {
t.Fatalf("decode TOTP secret: %v", err)
}
currentAdminAuth = &adminAuthConfig{
basePath: "/admin",
totpSecret: decodedSecret,
jwtSecret: []byte("test-jwt-secret"),
sessionTTL: time.Hour,
}
*secure = false
*asTemplate = false
*adminEnabled = false
loadedHooksMu.Lock()
loadedHooksFromFiles = make(map[string]hook.Hooks)
hooksFiles = nil
loadedHooksMu.Unlock()
return func() {
currentAdminAuth = savedAdminAuth
*secure = savedSecure
*asTemplate = savedAsTemplate
*adminEnabled = savedAdminEnabled
loadedHooksMu.Lock()
loadedHooksFromFiles = savedLoaded
hooksFiles = savedHooksFiles
loadedHooksMu.Unlock()
}
}
func setLoadedHooksForTest(path string, hooksInFile hook.Hooks) {
loadedHooksMu.Lock()
defer loadedHooksMu.Unlock()
loadedHooksFromFiles = map[string]hook.Hooks{
path: cloneHooks(hooksInFile),
}
hooksFiles = hook.HooksFiles{path}
}
func readHooksFileForTest(t *testing.T, path string) hook.Hooks {
t.Helper()
var hooksInFile hook.Hooks
if err := hooksInFile.LoadFromFile(path, false); err != nil {
t.Fatalf("load hooks file %s: %v", path, err)
}
return hooksInFile
}
func loginCookieForTest(t *testing.T) *http.Cookie {
t.Helper()
code := hotpCode(currentAdminAuth.totpSecret, uint64(time.Now().Unix()/30), 6)
req := httptest.NewRequest(http.MethodPost, "/admin/api/auth/login", strings.NewReader(`{"code":"`+code+`"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
adminLoginHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == adminTokenCookieName {
return cookie
}
}
t.Fatal("admin login did not issue session cookie")
return nil
}
func TestVerifyTOTP(t *testing.T) {
secret, err := decodeTOTPSecret(testAdminBase32Secret)
if err != nil {
t.Fatalf("decode TOTP secret: %v", err)
}
if got := hotpCode(secret, 1, 6); got != "287082" {
t.Fatalf("hotpCode(...) = %q, want %q", got, "287082")
}
if !verifyTOTP(secret, "287082", time.Unix(59, 0)) {
t.Fatal("verifyTOTP should accept the RFC test vector")
}
if verifyTOTP(secret, "287083", time.Unix(59, 0)) {
t.Fatal("verifyTOTP should reject an invalid code")
}
}
func TestAdminJWTRoundTrip(t *testing.T) {
restore := setupAdminTestState(t)
defer restore()
now := time.Unix(1700000000, 0)
token, err := signAdminJWT(now)
if err != nil {
t.Fatalf("signAdminJWT: %v", err)
}
claims, err := parseAdminJWT(token, now.Add(10*time.Minute))
if err != nil {
t.Fatalf("parseAdminJWT: %v", err)
}
if claims.Subject != "webhook-admin" {
t.Fatalf("claims.Subject = %q, want %q", claims.Subject, "webhook-admin")
}
if _, err := parseAdminJWT(token, now.Add(2*time.Hour)); err == nil {
t.Fatal("parseAdminJWT should reject expired tokens")
}
}
func TestAdminConfigRequiresAuth(t *testing.T) {
restore := setupAdminTestState(t)
defer restore()
handler := adminRequireAuth(adminConfigHandler)
req := httptest.NewRequest(http.MethodGet, "/admin/api/config", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}
func TestAdminCRUDHandlers(t *testing.T) {
restore := setupAdminTestState(t)
defer restore()
tmpDir := t.TempDir()
hooksPath := filepath.Join(tmpDir, "hooks.json")
initialHooks := hook.Hooks{
{
ID: "deploy",
ExecuteCommand: "/bin/echo",
ResponseMessage: "ok",
HTTPMethods: []string{"POST"},
},
}
if err := writeHooksFile(hooksPath, initialHooks); err != nil {
t.Fatalf("writeHooksFile: %v", err)
}
setLoadedHooksForTest(hooksPath, initialHooks)
cookie := loginCookieForTest(t)
configReq := httptest.NewRequest(http.MethodGet, "/admin/api/config", nil)
configReq.AddCookie(cookie)
configRec := httptest.NewRecorder()
adminRequireAuth(adminConfigHandler)(configRec, configReq)
if configRec.Code != http.StatusOK {
t.Fatalf("config status = %d, want %d", configRec.Code, http.StatusOK)
}
var config adminHooksState
if err := json.Unmarshal(configRec.Body.Bytes(), &config); err != nil {
t.Fatalf("decode config response: %v", err)
}
if len(config.Files) != 1 || len(config.Files[0].Hooks) != 1 {
t.Fatalf("unexpected config payload: %+v", config)
}
createReq := httptest.NewRequest(http.MethodPost, "/admin/api/hooks", strings.NewReader(`{
"file": "`+hooksPath+`",
"hook": {
"id": "build",
"execute-command": "/usr/bin/env",
"response-message": "created",
"http-methods": [" post ", "get"]
}
}`))
createReq.Header.Set("Content-Type", "application/json")
createReq.AddCookie(cookie)
createRec := httptest.NewRecorder()
adminRequireAuth(adminCreateHookHandler)(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d, body=%s", createRec.Code, http.StatusCreated, createRec.Body.String())
}
hooksAfterCreate := readHooksFileForTest(t, hooksPath)
if len(hooksAfterCreate) != 2 {
t.Fatalf("hook count after create = %d, want %d", len(hooksAfterCreate), 2)
}
if hooksAfterCreate.Match("build") == nil {
t.Fatal("expected created hook to be written to file")
}
if got := hooksAfterCreate.Match("build").HTTPMethods; len(got) != 2 || got[0] != "POST" || got[1] != "GET" {
t.Fatalf("created hook methods = %#v, want %#v", got, []string{"POST", "GET"})
}
updateReq := httptest.NewRequest(http.MethodPut, "/admin/api/hooks", strings.NewReader(`{
"file": "`+hooksPath+`",
"currentId": "build",
"hook": {
"id": "build-renamed",
"execute-command": "/bin/echo",
"response-message": "updated",
"http-methods": ["PATCH"]
}
}`))
updateReq.Header.Set("Content-Type", "application/json")
updateReq.AddCookie(cookie)
updateRec := httptest.NewRecorder()
adminRequireAuth(adminUpdateHookHandler)(updateRec, updateReq)
if updateRec.Code != http.StatusOK {
t.Fatalf("update status = %d, want %d, body=%s", updateRec.Code, http.StatusOK, updateRec.Body.String())
}
hooksAfterUpdate := readHooksFileForTest(t, hooksPath)
if hooksAfterUpdate.Match("build") != nil {
t.Fatal("expected old hook ID to be removed after rename")
}
renamed := hooksAfterUpdate.Match("build-renamed")
if renamed == nil {
t.Fatal("expected renamed hook to be persisted")
}
if renamed.ResponseMessage != "updated" {
t.Fatalf("updated response message = %q, want %q", renamed.ResponseMessage, "updated")
}
deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/api/hooks", strings.NewReader(`{
"file": "`+hooksPath+`",
"currentId": "build-renamed"
}`))
deleteReq.Header.Set("Content-Type", "application/json")
deleteReq.AddCookie(cookie)
deleteRec := httptest.NewRecorder()
adminRequireAuth(adminDeleteHookHandler)(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("delete status = %d, want %d, body=%s", deleteRec.Code, http.StatusOK, deleteRec.Body.String())
}
hooksAfterDelete := readHooksFileForTest(t, hooksPath)
if len(hooksAfterDelete) != 1 {
t.Fatalf("hook count after delete = %d, want %d", len(hooksAfterDelete), 1)
}
if hooksAfterDelete.Match("build-renamed") != nil {
t.Fatal("expected deleted hook to be removed from file")
}
}
func TestAdminConfigEmptyHooksUsesArray(t *testing.T) {
restore := setupAdminTestState(t)
defer restore()
hooksPath := filepath.Join(t.TempDir(), "hooks.json")
setLoadedHooksForTest(hooksPath, nil)
cookie := loginCookieForTest(t)
req := httptest.NewRequest(http.MethodGet, "/admin/api/config", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
adminRequireAuth(adminConfigHandler)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("config status = %d, want %d", rec.Code, http.StatusOK)
}
body := rec.Body.String()
if strings.Contains(body, `"hooks":null`) {
t.Fatalf("config response should not contain null hooks: %s", body)
}
if !strings.Contains(body, `"hooks":[]`) {
t.Fatalf("config response should contain empty hooks array: %s", body)
}
}
func TestAdminUpdateHookWithSlashIDViaRouter(t *testing.T) {
restore := setupAdminTestState(t)
defer restore()
*adminEnabled = true
defer func() {
*adminEnabled = false
}()
tmpDir := t.TempDir()
hooksPath := filepath.Join(tmpDir, "hooks.json")
initialHooks := hook.Hooks{
{
ID: "iot/thjk/web",
ExecuteCommand: "/bin/echo",
ResponseMessage: "ok",
HTTPMethods: []string{"POST"},
},
}
if err := writeHooksFile(hooksPath, initialHooks); err != nil {
t.Fatalf("writeHooksFile: %v", err)
}
setLoadedHooksForTest(hooksPath, initialHooks)
router := mux.NewRouter()
registerAdminRoutes(router)
cookie := loginCookieForTest(t)
req := httptest.NewRequest(http.MethodPut, "/admin/api/hooks", strings.NewReader(`{
"file": "`+hooksPath+`",
"currentId": "iot/thjk/web",
"hook": {
"id": "iot/thjk/web",
"execute-command": "/usr/bin/env",
"response-message": "updated slash id"
}
}`))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("slash id update status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
updatedHooks := readHooksFileForTest(t, hooksPath)
updated := updatedHooks.Match("iot/thjk/web")
if updated == nil {
t.Fatal("expected slash-id hook to still exist after update")
}
if updated.ExecuteCommand != "/usr/bin/env" {
t.Fatalf("updated execute-command = %q, want %q", updated.ExecuteCommand, "/usr/bin/env")
}
}
func TestAdminWritesBlockedInTemplateMode(t *testing.T) {
restore := setupAdminTestState(t)
defer restore()
tmpDir := t.TempDir()
hooksPath := filepath.Join(tmpDir, "hooks.json")
setLoadedHooksForTest(hooksPath, hook.Hooks{})
*asTemplate = true
err := upsertHookInFile(hooksPath, "", hook.Hook{ID: "blocked"})
if err == nil || !strings.Contains(err.Error(), errAdminReadOnly.Error()) {
t.Fatalf("upsertHookInFile error = %v, want %v", err, errAdminReadOnly)
}
}

41
admin_ui.go Normal file
View file

@ -0,0 +1,41 @@
package main
import (
"embed"
"html/template"
"io/fs"
"net/http"
)
//go:embed adminui/index.html adminui/assets/*
var adminUIFiles embed.FS
var (
adminIndexTemplate = template.Must(template.ParseFS(adminUIFiles, "adminui/index.html"))
adminStaticFS = mustAdminSub(adminUIFiles, "adminui")
)
type adminUIData struct {
BasePath string
}
func mustAdminSub(fsys fs.FS, dir string) fs.FS {
sub, err := fs.Sub(fsys, dir)
if err != nil {
panic(err)
}
return sub
}
func adminUIHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := adminIndexTemplate.Execute(w, adminUIData{BasePath: currentAdminAuth.basePath}); err != nil {
http.Error(w, "admin UI render failed", http.StatusInternalServerError)
}
}
func adminStaticHandler() http.Handler {
return http.FileServer(http.FS(adminStaticFS))
}

1275
adminui/assets/app.js Normal file

File diff suppressed because it is too large Load diff

608
adminui/assets/style.css Normal file
View file

@ -0,0 +1,608 @@
:root {
--bg: #f4efe7;
--bg-strong: #fffaf3;
--panel: rgba(255, 252, 247, 0.9);
--ink: #1f2a24;
--muted: #607067;
--line: rgba(31, 42, 36, 0.12);
--line-strong: rgba(31, 42, 36, 0.18);
--accent: #0f6d5a;
--accent-strong: #0a4f42;
--danger: #b0453c;
--danger-bg: rgba(176, 69, 60, 0.1);
--warn-bg: rgba(183, 123, 32, 0.12);
--muted-bg: rgba(31, 42, 36, 0.05);
--shadow: 0 24px 64px rgba(31, 42, 36, 0.12);
--radius: 22px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15, 109, 90, 0.16), transparent 26rem),
radial-gradient(circle at bottom right, rgba(176, 69, 60, 0.1), transparent 24rem),
linear-gradient(180deg, #faf5ee 0%, #efe6db 100%);
font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
}
.shell {
width: min(1440px, calc(100vw - 32px));
margin: 24px auto 40px;
}
.hero {
padding: 28px 30px;
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(247, 241, 233, 0.84));
box-shadow: var(--shadow);
}
.eyebrow {
display: inline-block;
padding: 6px 10px;
border-radius: 999px;
background: rgba(15, 109, 90, 0.1);
color: var(--accent-strong);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1 {
margin: 16px 0 10px;
font-size: clamp(30px, 5vw, 48px);
line-height: 1.02;
letter-spacing: -0.04em;
}
.hero p,
.panel-subtitle,
.section-head p,
.tiny,
.meta-card,
.status,
.banner,
.preview pre,
.hook-item small {
line-height: 1.55;
}
.hero p {
margin: 0;
max-width: 860px;
color: var(--muted);
font-size: 15px;
}
.workspace {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 20px;
margin-top: 20px;
}
.workspace[hidden],
.login[hidden] {
display: none;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
box-shadow: var(--shadow);
backdrop-filter: blur(14px);
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 18px 20px 0;
}
.panel-head h2,
.panel-head h3,
.toolbar-row h3,
.toolbar-row h4,
.section-head h3 {
margin: 0;
letter-spacing: -0.02em;
}
.panel-head h2 {
font-size: 18px;
}
.panel-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--muted);
}
.panel-body {
padding: 18px 20px 20px;
}
.login {
max-width: 420px;
margin: 28px auto 0;
}
.stack {
display: grid;
gap: 14px;
}
label {
display: block;
margin-bottom: 8px;
font-size: 12px;
font-weight: 700;
color: var(--muted);
letter-spacing: 0.03em;
text-transform: uppercase;
}
input,
select,
button {
font: inherit;
}
input,
select {
width: 100%;
height: 44px;
padding: 0 14px;
border: 1px solid rgba(31, 42, 36, 0.18);
border-radius: 14px;
background: rgba(255, 255, 255, 0.96);
color: var(--ink);
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
input:focus,
select:focus {
outline: none;
border-color: rgba(15, 109, 90, 0.55);
box-shadow: 0 0 0 4px rgba(15, 109, 90, 0.12);
transform: translateY(-1px);
}
code,
pre,
.hook-item strong,
.code-input {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
}
.code-input {
letter-spacing: 0.35em;
text-align: center;
font-size: 22px;
}
button {
border: 0;
border-radius: 14px;
min-height: 42px;
padding: 0 16px;
cursor: pointer;
transition: transform 150ms ease, opacity 150ms ease, box-shadow 150ms ease;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 12px 24px rgba(31, 42, 36, 0.12);
}
button:disabled {
opacity: 0.48;
cursor: not-allowed;
box-shadow: none;
}
.primary {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #fff;
}
.secondary {
background: rgba(31, 42, 36, 0.08);
color: var(--ink);
}
.ghost {
background: transparent;
color: var(--accent-strong);
border: 1px dashed rgba(15, 109, 90, 0.28);
}
.danger {
background: rgba(176, 69, 60, 0.14);
color: var(--danger);
}
.meta-card,
.status,
.banner {
padding: 14px 16px;
border-radius: 16px;
font-size: 13px;
}
.meta-card {
background: rgba(15, 109, 90, 0.08);
color: var(--accent-strong);
}
.status {
margin-top: 14px;
border: 1px solid rgba(31, 42, 36, 0.08);
}
.status-muted {
background: var(--muted-bg);
color: var(--muted);
}
.status-error {
background: var(--danger-bg);
border-color: rgba(176, 69, 60, 0.18);
color: #7b3029;
}
.status-success {
background: rgba(15, 109, 90, 0.1);
border-color: rgba(15, 109, 90, 0.18);
color: var(--accent-strong);
}
.banner {
margin-bottom: 16px;
background: var(--warn-bg);
color: #81561d;
border: 1px solid rgba(183, 123, 32, 0.18);
}
.toolbar-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.toolbar-row h3,
.toolbar-row h4 {
font-size: 15px;
}
.hook-list {
display: grid;
gap: 10px;
max-height: 58vh;
overflow: auto;
padding-right: 2px;
}
.hook-item {
width: 100%;
padding: 14px;
border: 1px solid rgba(31, 42, 36, 0.1);
border-radius: 16px;
background: rgba(255, 255, 255, 0.88);
cursor: pointer;
text-align: left;
}
.hook-item.active {
border-color: rgba(15, 109, 90, 0.42);
background: rgba(15, 109, 90, 0.1);
}
.hook-item strong {
display: block;
font-size: 13px;
line-height: 1.4;
}
.hook-item small {
display: block;
margin-top: 6px;
color: var(--muted);
font-size: 12px;
}
.hook-form {
display: grid;
gap: 16px;
}
.editor-fieldset {
margin: 0;
padding: 0;
border: 0;
min-width: 0;
}
.section,
.preview {
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.55);
}
.section {
padding: 18px;
}
.section-head {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.section-head h3 {
font-size: 16px;
}
.section-head p {
margin: 0;
color: var(--muted);
font-size: 13px;
max-width: 560px;
}
.grid {
display: grid;
gap: 14px;
}
.grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-span-2 {
grid-column: span 2;
}
.toggle-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.toggle {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255, 255, 255, 0.7);
cursor: pointer;
}
.toggle input {
width: 18px;
height: 18px;
margin: 0;
padding: 0;
accent-color: var(--accent);
}
.toggle span {
font-size: 14px;
line-height: 1.45;
}
.subsection + .subsection {
margin-top: 18px;
}
.repeatable-list {
display: grid;
gap: 10px;
}
.repeatable-empty {
padding: 14px 16px;
border: 1px dashed rgba(31, 42, 36, 0.18);
border-radius: 16px;
color: var(--muted);
font-size: 13px;
}
.repeatable-row {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255, 255, 255, 0.8);
}
.repeatable-row.columns-2 {
grid-template-columns: repeat(2, minmax(0, 1fr)) 44px;
}
.repeatable-row.columns-3 {
grid-template-columns: repeat(3, minmax(0, 1fr)) 44px;
}
.repeatable-row.columns-4 {
grid-template-columns: repeat(4, minmax(0, 1fr)) 44px;
}
.repeatable-row.columns-5 {
grid-template-columns: repeat(5, minmax(0, 1fr)) 44px;
}
.field-inline-label {
display: block;
margin-bottom: 6px;
font-size: 11px;
font-weight: 700;
color: var(--muted);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.row-check {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
}
.row-check input {
width: 18px;
height: 18px;
margin: 0;
padding: 0;
accent-color: var(--accent);
}
.icon-button {
width: 44px;
min-height: 44px;
padding: 0;
border-radius: 14px;
background: rgba(176, 69, 60, 0.12);
color: var(--danger);
}
.rule-editor {
display: grid;
gap: 12px;
}
.rule-card {
border: 1px solid var(--line-strong);
border-radius: 18px;
background: rgba(255, 255, 255, 0.82);
padding: 14px;
}
.rule-card[data-depth="1"] {
margin-left: 14px;
}
.rule-card[data-depth="2"] {
margin-left: 28px;
}
.rule-card[data-depth="3"] {
margin-left: 42px;
}
.rule-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.rule-head strong {
font-size: 14px;
}
.rule-children {
display: grid;
gap: 12px;
margin-top: 14px;
}
.rule-note {
padding: 12px 14px;
border-radius: 14px;
background: var(--muted-bg);
color: var(--muted);
font-size: 13px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 16px;
}
.preview {
margin-top: 16px;
overflow: hidden;
}
.preview summary {
padding: 14px 16px;
cursor: pointer;
font-weight: 700;
}
.preview pre {
margin: 0;
padding: 0 16px 16px;
color: var(--ink);
overflow: auto;
font-size: 12px;
}
.tiny {
margin-top: 10px;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 1180px) {
.workspace {
grid-template-columns: 1fr;
}
}
@media (max-width: 920px) {
.grid-2,
.grid-3,
.toggle-grid,
.repeatable-row.columns-2,
.repeatable-row.columns-3,
.repeatable-row.columns-4,
.repeatable-row.columns-5 {
grid-template-columns: 1fr;
}
.grid-span-2 {
grid-column: auto;
}
.repeatable-row.columns-2 .icon-button,
.repeatable-row.columns-3 .icon-button,
.repeatable-row.columns-4 .icon-button,
.repeatable-row.columns-5 .icon-button {
width: 100%;
}
.rule-card[data-depth="1"],
.rule-card[data-depth="2"],
.rule-card[data-depth="3"] {
margin-left: 0;
}
}

223
adminui/index.html Normal file
View file

@ -0,0 +1,223 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Webhook Admin</title>
<link rel="stylesheet" href="{{.BasePath}}/assets/style.css">
<script>
window.__WEBHOOK_ADMIN__ = {
basePath: {{.BasePath}}
};
</script>
<script src="{{.BasePath}}/assets/app.js" defer></script>
</head>
<body>
<div class="shell">
<section class="hero">
<span class="eyebrow">Webhook Control Plane</span>
<h1>集中管理所有 webhook 配置</h1>
<p>使用 TOTP 获取短期 JWT 会话。页面直接管理已加载的 hooks 文件,不需要额外前端构建流程。</p>
</section>
<section id="loginPanel" class="panel login">
<div class="panel-head">
<h2>管理员登录</h2>
</div>
<div class="panel-body">
<form id="loginForm" class="stack">
<div>
<label for="totpCode">TOTP 动态码</label>
<input id="totpCode" class="code-input" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="123456">
</div>
<button class="primary" type="submit">登录</button>
</form>
<div class="tiny"><code>-admin-totp-secret</code> 配到你的认证器后,输入当前 6 位验证码即可登录。</div>
<div id="loginStatus" class="status status-muted">未登录。</div>
</div>
</section>
<section id="workspace" class="workspace" hidden>
<aside class="panel sidebar">
<div class="panel-head">
<h2>配置文件</h2>
<button id="refreshBtn" class="secondary" type="button">刷新</button>
</div>
<div class="panel-body stack">
<div>
<label for="fileSelect">当前文件</label>
<select id="fileSelect"></select>
</div>
<div id="fileMeta" class="meta-card">暂无可管理的 hooks 文件。</div>
<div class="toolbar-row">
<h3>Hooks</h3>
<button id="newHookBtn" class="secondary" type="button">新建</button>
</div>
<div id="hookList" class="hook-list"></div>
</div>
</aside>
<main class="panel editor">
<div class="panel-head">
<div>
<h2>结构化编辑器</h2>
<p class="panel-subtitle">按区块维护 hook 定义,避免整段 JSON 记忆负担。</p>
</div>
<button id="logoutBtn" class="secondary" type="button">退出</button>
</div>
<div class="panel-body">
<div id="readOnlyBanner" class="banner" hidden></div>
<fieldset id="editorFieldset" class="editor-fieldset">
<form id="hookForm" class="hook-form" novalidate>
<section class="section">
<div class="section-head">
<h3>Basics</h3>
<p>基本标识、执行命令和请求方法。</p>
</div>
<div class="grid grid-2">
<div>
<label for="hookId">Hook ID</label>
<input id="hookId" placeholder="deploy-webhook">
</div>
<div>
<label for="executeCommand">Execute Command</label>
<input id="executeCommand" placeholder="/usr/local/bin/deploy.sh">
</div>
<div>
<label for="commandWorkingDirectory">Working Directory</label>
<input id="commandWorkingDirectory" placeholder="/srv/app">
</div>
<div>
<label for="commandTimeout">Command Timeout</label>
<input id="commandTimeout" placeholder="30s">
</div>
<div>
<label for="httpMethods">HTTP Methods</label>
<input id="httpMethods" placeholder="POST, GET">
</div>
<div class="grid-span-2">
<label for="responseMessage">Response Message</label>
<input id="responseMessage" placeholder="Webhook triggered">
</div>
</div>
</section>
<section class="section">
<div class="section-head">
<h3>Behavior</h3>
<p>响应码、内容类型和执行行为开关。</p>
</div>
<div class="grid grid-3">
<div>
<label for="incomingPayloadContentType">Incoming Payload Content-Type</label>
<input id="incomingPayloadContentType" placeholder="application/json">
</div>
<div>
<label for="successHttpResponseCode">Success HTTP Code</label>
<input id="successHttpResponseCode" inputmode="numeric" placeholder="200">
</div>
<div>
<label for="triggerRuleMismatchHttpResponseCode">Rule Mismatch HTTP Code</label>
<input id="triggerRuleMismatchHttpResponseCode" inputmode="numeric" placeholder="403">
</div>
<div>
<label for="maxConcurrency">Max Concurrency</label>
<input id="maxConcurrency" inputmode="numeric" placeholder="0">
</div>
</div>
<div class="toggle-grid">
<label class="toggle">
<input id="captureCommandOutput" type="checkbox">
<span>Include command output in response</span>
</label>
<label class="toggle">
<input id="captureCommandOutputOnError" type="checkbox">
<span>Include command output on error</span>
</label>
<label class="toggle">
<input id="triggerSignatureSoftFailures" type="checkbox">
<span>Allow trigger signature soft failures</span>
</label>
<label class="toggle">
<input id="keepFileEnvironment" type="checkbox">
<span>Keep uploaded files as environment references</span>
</label>
</div>
</section>
<section class="section">
<div class="section-head">
<h3>Response Headers</h3>
<p>为 hook 响应追加静态响应头。</p>
</div>
<div id="responseHeadersRows" class="repeatable-list"></div>
<button id="addResponseHeaderBtn" class="ghost" type="button">添加响应头</button>
</section>
<section class="section">
<div class="section-head">
<h3>Command Inputs</h3>
<p>把请求中的值传到命令参数、环境变量或文件。</p>
</div>
<div class="subsection">
<div class="toolbar-row">
<h4>Pass Arguments To Command</h4>
<button id="addPassArgumentBtn" class="ghost" type="button">添加参数</button>
</div>
<div id="passArgumentsRows" class="repeatable-list"></div>
</div>
<div class="subsection">
<div class="toolbar-row">
<h4>Pass Environment To Command</h4>
<button id="addPassEnvironmentBtn" class="ghost" type="button">添加环境变量</button>
</div>
<div id="passEnvironmentRows" class="repeatable-list"></div>
</div>
<div class="subsection">
<div class="toolbar-row">
<h4>Pass File To Command</h4>
<button id="addPassFileBtn" class="ghost" type="button">添加文件参数</button>
</div>
<div id="passFileRows" class="repeatable-list"></div>
</div>
<div class="subsection">
<div class="toolbar-row">
<h4>Parse Parameters As JSON</h4>
<button id="addParseJSONBtn" class="ghost" type="button">添加 JSON 参数</button>
</div>
<div id="parseJSONRows" class="repeatable-list"></div>
</div>
</section>
<section class="section">
<div class="section-head">
<h3>Trigger Rule</h3>
<p>用树形表单维护 and/or/not/match 规则,不再直接手写整段对象。</p>
</div>
<div id="ruleEditor" class="rule-editor"></div>
</section>
</form>
</fieldset>
<div class="actions">
<button id="saveBtn" class="primary" type="button">保存</button>
<button id="deleteBtn" class="danger" type="button">删除</button>
</div>
<details class="preview">
<summary>JSON Preview</summary>
<pre id="jsonPreview"></pre>
</details>
<div id="workspaceStatus" class="status status-muted">已准备就绪。</div>
</div>
</main>
</section>
</div>
</body>
</html>

118
config.go Normal file
View file

@ -0,0 +1,118 @@
package main
import (
"flag"
"fmt"
"os"
"strings"
"gopkg.in/yaml.v2"
)
// configFile holds the path to the YAML configuration file.
// It is extracted from os.Args before flag.Parse() runs.
var configFile string
// loadConfigFile reads a YAML configuration file and sets the corresponding
// flags. Flags set via the command line take precedence over the config file
// because this function is called before flag.Parse().
//
// Supported YAML structure:
//
// ip: 0.0.0.0
// port: 9000
// nopanic: true
// hooks:
// - /etc/webhook/hooks.yaml
// header:
// - "X-Custom=value"
func loadConfigFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read config file %q: %w", path, err)
}
// Use a generic map so we can handle both scalar and list values.
var cfg map[string]interface{}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("failed to parse config file %q: %w", path, err)
}
for key, val := range cfg {
f := flag.CommandLine.Lookup(key)
if f == nil {
return fmt.Errorf("unknown option %q in config file %q", key, path)
}
switch v := val.(type) {
case []interface{}:
// List values: call flag.Set for each element (supports -hooks, -header, etc.)
for _, item := range v {
s := fmt.Sprintf("%v", item)
if err := flag.CommandLine.Set(key, s); err != nil {
return fmt.Errorf("error setting flag %q to %q: %w", key, s, err)
}
}
case bool:
s := "false"
if v {
s = "true"
}
if err := flag.CommandLine.Set(key, s); err != nil {
return fmt.Errorf("error setting flag %q to %q: %w", key, s, err)
}
default:
s := fmt.Sprintf("%v", v)
if err := flag.CommandLine.Set(key, s); err != nil {
return fmt.Errorf("error setting flag %q to %q: %w", key, s, err)
}
}
}
return nil
}
// extractConfigFlag scans os.Args for -config / -c / --config and returns the
// path value. It also removes those arguments from os.Args so that flag.Parse()
// does not see them (since -config is not a registered flag).
func extractConfigFlag() string {
var path string
var newArgs []string
args := os.Args[1:] // skip program name
newArgs = append(newArgs, os.Args[0])
for i := 0; i < len(args); i++ {
arg := args[i]
var matched bool
var value string
switch {
case arg == "-c" || arg == "-config" || arg == "--config":
matched = true
if i+1 < len(args) {
i++
value = args[i]
}
case strings.HasPrefix(arg, "-c="):
matched = true
value = strings.TrimPrefix(arg, "-c=")
case strings.HasPrefix(arg, "-config="):
matched = true
value = strings.TrimPrefix(arg, "-config=")
case strings.HasPrefix(arg, "--config="):
matched = true
value = strings.TrimPrefix(arg, "--config=")
}
if matched {
path = value
} else {
newArgs = append(newArgs, arg)
}
}
os.Args = newArgs
return path
}

View file

@ -7,6 +7,8 @@ Hooks are defined as objects in the JSON or YAML hooks configuration file. Pleas
* `id` - specifies the ID of your hook. This value is used to create the HTTP endpoint (http://yourserver:port/hooks/your-hook-id)
* `execute-command` - specifies the command that should be executed when the hook is triggered
* `command-working-directory` - specifies the working directory that will be used for the script when it's executed
* `command-timeout` - overrides the default command execution timeout for this hook. Accepts a Go duration string such as `30s`, `2m`, or `1h30m`. An empty value inherits the global `-command-timeout` flag. A value of `0` disables the timeout for this hook.
* `max-concurrency` - overrides the default maximum number of concurrent executions for this hook. An empty value inherits the global `-max-concurrency` flag. A value of `0` disables the limit for this hook.
* `response-message` - specifies the string that will be returned to the hook initiator
* `response-headers` - specifies the list of headers in format `{"name": "X-Example-Header", "value": "it works"}` that will be returned in HTTP response for the hook
* `success-http-response-code` - specifies the HTTP status code to be returned upon success
@ -23,6 +25,7 @@ Hooks are defined as objects in the JSON or YAML hooks configuration file. Pleas
* `trigger-rule` - specifies the rule that will be evaluated in order to determine should the hook be triggered. Check [Hook rules page](Hook-Rules.md) to see the list of valid rules and their usage
* `trigger-rule-mismatch-http-response-code` - specifies the HTTP status code to be returned when the trigger rule is not satisfied
* `trigger-signature-soft-failures` - allow signature validation failures within Or rules; by default, signature failures are treated as errors.
* `keep-file-environment` - expose uploaded multipart files to the executed command as temporary environment variables. For a multipart form field named `pkg`, webhook will provide `HOOK_FILE_PKG` with the temporary file path and `HOOK_FILENAME_PKG` with the original filename. These files exist only for the lifetime of the command execution and are removed afterwards. Multipart field names are uppercased and embedded into the environment variable name verbatim; if you plan to read them from a shell script, prefer field names that are safe shell variable suffixes such as letters, numbers, and underscores.
## Examples
Check out [Hook examples page](Hook-Examples.md) for more complex examples of hooks.

View file

@ -657,9 +657,11 @@ Content-Disposition: form-data; name="payload"
Content-Disposition: form-data; name="thumb"; filename="thumb.jpg"
```
We key off of the `name` attribute in the `Content-Disposition` value.
## Pass string arguments to command
We key off of the `name` attribute in the `Content-Disposition` value.
If you need the executed command to read uploaded files directly, enable `keep-file-environment`. For a file part named `pkg`, webhook will expose `HOOK_FILE_PKG` with the temporary file path and `HOOK_FILENAME_PKG` with the original filename while the command is running, then clean the file up when the command exits. Since the multipart field name is copied into the environment variable name after uppercasing, prefer shell-safe field names such as `pkg`, `release_tarball`, or `artifact1`.
## Pass string arguments to command
To pass simple string arguments to a command, use the `string` parameter source.
The following example will pass two static string parameters ("-e 123123") to the

View file

@ -5,6 +5,8 @@ Usage of webhook:
path to the HTTPS certificate pem file (default "cert.pem")
-cipher-suites string
comma-separated list of supported TLS cipher suites
-command-timeout string
default timeout for hook command execution (for example 30s); 0 disables the timeout
-debug
show debug output
-header value
@ -23,6 +25,8 @@ Usage of webhook:
list available TLS cipher suites
-logfile string
send log output to a file; implicitly enables verbose logging
-max-concurrency int
default maximum number of concurrent executions per hook; 0 disables the limit
-max-multipart-mem int
maximum memory in bytes for parsing multipart form data before disk caching (default 1048576)
-nopanic
@ -57,6 +61,8 @@ Usage of webhook:
Use any of the above specified flags to override their default behavior.
`-command-timeout` and `-max-concurrency` act as defaults for all hooks. Individual hooks can override them using the `command-timeout` and `max-concurrency` hook properties. Within a hook definition, `0` explicitly disables the inherited limit.
# Live reloading hooks
If you are running an OS that supports the HUP or USR1 signal, you can use it to trigger hooks reload from hooks file, without restarting the webhook instance.
```bash

146
execution.go Normal file
View file

@ -0,0 +1,146 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/adnanh/webhook/internal/hook"
)
var (
commandTimeout = flag.String("command-timeout", "", "default timeout for hook command execution (for example 30s); 0 disables the timeout")
maxConcurrency = flag.Int("max-concurrency", 0, "default maximum number of concurrent executions per hook; 0 disables the limit")
defaultExecTimeout time.Duration
errHookConcurrencyLimit = errors.New("hook concurrency limit reached")
)
type hookExecutionLimiter struct {
limit int
tokens chan struct{}
}
var hookExecutionLimiters = struct {
mu sync.Mutex
byHook map[string]*hookExecutionLimiter
}{
byHook: make(map[string]*hookExecutionLimiter),
}
func initExecutionSettings() error {
value := strings.TrimSpace(*commandTimeout)
switch value {
case "", "0":
defaultExecTimeout = 0
default:
timeout, err := time.ParseDuration(value)
if err != nil {
return fmt.Errorf("invalid command-timeout: %w", err)
}
if timeout < 0 {
return errors.New("command-timeout must be zero or greater")
}
defaultExecTimeout = timeout
}
if *maxConcurrency < 0 {
return errors.New("max-concurrency must be zero or greater")
}
return nil
}
func handleHook(h *hook.Hook, r *hook.Request) (string, error) {
release, err := acquireHookExecutionSlot(h)
if err != nil {
return "", err
}
return executeHook(h, r, true, release)
}
func handleHookAsync(h *hook.Hook, r *hook.Request) error {
release, err := acquireHookExecutionSlot(h)
if err != nil {
return err
}
go func() {
_, _ = executeHook(h, r, false, release)
}()
return nil
}
func hookExecutionErrorStatus(err error) (int, string) {
if errors.Is(err, errHookConcurrencyLimit) {
return http.StatusServiceUnavailable, "Hook concurrency limit exceeded. Please try again later."
}
return http.StatusInternalServerError, "Error occurred while executing the hook's command. Please check your logs for more details."
}
func acquireHookExecutionSlot(h *hook.Hook) (func(), error) {
limit, err := h.EffectiveMaxConcurrency(*maxConcurrency)
if err != nil {
return nil, err
}
if limit == 0 {
return func() {}, nil
}
limiter := executionLimiterForHook(h.ID, limit)
select {
case limiter.tokens <- struct{}{}:
return func() {
<-limiter.tokens
}, nil
default:
return nil, fmt.Errorf("%w (%d running)", errHookConcurrencyLimit, limit)
}
}
func executionLimiterForHook(id string, limit int) *hookExecutionLimiter {
hookExecutionLimiters.mu.Lock()
defer hookExecutionLimiters.mu.Unlock()
limiter := hookExecutionLimiters.byHook[id]
if limiter == nil || limiter.limit != limit {
limiter = &hookExecutionLimiter{
limit: limit,
tokens: make(chan struct{}, limit),
}
hookExecutionLimiters.byHook[id] = limiter
}
return limiter
}
func hookExecutionContext(h *hook.Hook, r *hook.Request, waitForCommand bool) (context.Context, context.CancelFunc, bool, error) {
timeout, err := h.EffectiveCommandTimeout(defaultExecTimeout)
if err != nil {
return nil, nil, false, err
}
ctx := context.Background()
useContext := timeout > 0
if waitForCommand && r != nil && r.RawRequest != nil {
ctx = r.RawRequest.Context()
useContext = true
}
if timeout > 0 {
ctx, cancel := context.WithTimeout(ctx, timeout)
return ctx, cancel, true, nil
}
return ctx, func() {}, useContext, nil
}

BIN
images/img01.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
images/img02.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
images/img03.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -567,6 +567,8 @@ type Hook struct {
ID string `json:"id,omitempty"`
ExecuteCommand string `json:"execute-command,omitempty"`
CommandWorkingDirectory string `json:"command-working-directory,omitempty"`
CommandTimeout string `json:"command-timeout,omitempty"`
MaxConcurrency *int `json:"max-concurrency,omitempty"`
ResponseMessage string `json:"response-message,omitempty"`
ResponseHeaders ResponseHeaders `json:"response-headers,omitempty"`
CaptureCommandOutput bool `json:"include-command-output-in-response,omitempty"`
@ -581,6 +583,70 @@ type Hook struct {
IncomingPayloadContentType string `json:"incoming-payload-content-type,omitempty"`
SuccessHttpResponseCode int `json:"success-http-response-code,omitempty"`
HTTPMethods []string `json:"http-methods"`
KeepFileEnvironment bool `json:"keep-file-environment,omitempty"`
}
func parseCommandTimeout(value string) (time.Duration, error) {
value = strings.TrimSpace(value)
if value == "" || value == "0" {
return 0, nil
}
timeout, err := time.ParseDuration(value)
if err != nil {
return 0, err
}
if timeout < 0 {
return 0, errors.New("must be zero or greater")
}
return timeout, nil
}
// ValidateExecutionSettings normalizes and validates hook execution limits.
func (h *Hook) ValidateExecutionSettings() error {
h.CommandTimeout = strings.TrimSpace(h.CommandTimeout)
if h.CommandTimeout != "" {
if _, err := parseCommandTimeout(h.CommandTimeout); err != nil {
return fmt.Errorf("invalid command-timeout %q: %w", h.CommandTimeout, err)
}
}
if h.MaxConcurrency != nil && *h.MaxConcurrency < 0 {
return fmt.Errorf("invalid max-concurrency %d: must be zero or greater", *h.MaxConcurrency)
}
return nil
}
// EffectiveCommandTimeout returns the hook timeout or the inherited default.
func (h Hook) EffectiveCommandTimeout(defaultTimeout time.Duration) (time.Duration, error) {
if defaultTimeout < 0 {
return 0, errors.New("default command timeout must be zero or greater")
}
if h.CommandTimeout == "" {
return defaultTimeout, nil
}
return parseCommandTimeout(h.CommandTimeout)
}
// EffectiveMaxConcurrency returns the hook concurrency limit or the inherited default.
func (h Hook) EffectiveMaxConcurrency(defaultLimit int) (int, error) {
if defaultLimit < 0 {
return 0, errors.New("default max concurrency must be zero or greater")
}
if h.MaxConcurrency == nil {
return defaultLimit, nil
}
if *h.MaxConcurrency < 0 {
return 0, errors.New("max-concurrency must be zero or greater")
}
return *h.MaxConcurrency, nil
}
// ParseJSONParameters decodes specified arguments to JSON objects and replaces the
@ -778,7 +844,21 @@ func (h *Hooks) LoadFromFile(path string, asTemplate bool) error {
file = buf.Bytes()
}
return yaml.Unmarshal(file, h)
if err := yaml.Unmarshal(file, h); err != nil {
return err
}
for i := range *h {
if err := (*h)[i].ValidateExecutionSettings(); err != nil {
id := (*h)[i].ID
if id == "" {
id = fmt.Sprintf("#%d", i)
}
return fmt.Errorf("invalid hook %q: %w", id, err)
}
}
return nil
}
// Append appends hooks unless the new hooks contain a hook with an ID that already exists
@ -915,7 +995,11 @@ const (
// Evaluate MatchRule will return based on the type
func (r MatchRule) Evaluate(req *Request) (bool, error) {
if r.Type == IPWhitelist {
return CheckIPWhitelist(req.RawRequest.RemoteAddr, r.IPRange)
addr := req.RealIP
if addr == "" {
addr = req.RawRequest.RemoteAddr
}
return CheckIPWhitelist(addr, r.IPRange)
}
if r.Type == ScalrSignature {
return CheckScalrSignature(req, r.Secret, true)

View file

@ -6,6 +6,7 @@ import (
"reflect"
"strings"
"testing"
"time"
)
func TestGetParameter(t *testing.T) {
@ -351,20 +352,21 @@ func TestHookExtractCommandArguments(t *testing.T) {
// we test both cases where the name of the data is used as the name of the
// env key & the case where the hook definition sets the env var name to a
// fixed value using the envname construct like so::
// [
// {
// "id": "push",
// "execute-command": "bb2mm",
// "command-working-directory": "/tmp",
// "pass-environment-to-command":
// [
// {
// "source": "entire-payload",
// "envname": "PAYLOAD"
// },
// ]
// }
// ]
//
// [
// {
// "id": "push",
// "execute-command": "bb2mm",
// "command-working-directory": "/tmp",
// "pass-environment-to-command":
// [
// {
// "source": "entire-payload",
// "envname": "PAYLOAD"
// },
// ]
// }
// ]
var hookExtractCommandArgumentsForEnvTests = []struct {
exec string
args []Argument
@ -412,6 +414,108 @@ func TestHookExtractCommandArgumentsForEnv(t *testing.T) {
}
}
func intPtr(v int) *int {
return &v
}
func TestHookValidateExecutionSettings(t *testing.T) {
for _, tt := range []struct {
name string
hook Hook
wantErr bool
}{
{
name: "valid values",
hook: Hook{CommandTimeout: "250ms", MaxConcurrency: intPtr(2)},
},
{
name: "explicit unlimited values",
hook: Hook{CommandTimeout: "0", MaxConcurrency: intPtr(0)},
},
{
name: "invalid timeout",
hook: Hook{CommandTimeout: "not-a-duration"},
wantErr: true,
},
{
name: "invalid concurrency",
hook: Hook{MaxConcurrency: intPtr(-1)},
wantErr: true,
},
} {
err := tt.hook.ValidateExecutionSettings()
if (err != nil) != tt.wantErr {
t.Fatalf("%s: expected error=%v, got err=%v", tt.name, tt.wantErr, err)
}
}
}
func TestHookEffectiveExecutionSettings(t *testing.T) {
for _, tt := range []struct {
name string
hook Hook
defaultTimeout time.Duration
defaultConcurrency int
wantTimeout time.Duration
wantConcurrency int
wantTimeoutErr bool
wantConcurrencyErr bool
}{
{
name: "inherits defaults",
hook: Hook{},
defaultTimeout: 5 * time.Second,
defaultConcurrency: 3,
wantTimeout: 5 * time.Second,
wantConcurrency: 3,
},
{
name: "hook overrides defaults",
hook: Hook{CommandTimeout: "250ms", MaxConcurrency: intPtr(1)},
defaultTimeout: 5 * time.Second,
defaultConcurrency: 3,
wantTimeout: 250 * time.Millisecond,
wantConcurrency: 1,
},
{
name: "hook disables defaults",
hook: Hook{CommandTimeout: "0", MaxConcurrency: intPtr(0)},
defaultTimeout: 5 * time.Second,
defaultConcurrency: 3,
wantTimeout: 0,
wantConcurrency: 0,
},
{
name: "invalid timeout override",
hook: Hook{CommandTimeout: "bogus"},
wantTimeoutErr: true,
wantConcurrency: 0,
},
{
name: "invalid default concurrency",
hook: Hook{},
defaultConcurrency: -1,
wantConcurrencyErr: true,
},
} {
timeout, timeoutErr := tt.hook.EffectiveCommandTimeout(tt.defaultTimeout)
if (timeoutErr != nil) != tt.wantTimeoutErr {
t.Fatalf("%s: expected timeout error=%v, got err=%v", tt.name, tt.wantTimeoutErr, timeoutErr)
}
if timeoutErr == nil && timeout != tt.wantTimeout {
t.Fatalf("%s: expected timeout %v, got %v", tt.name, tt.wantTimeout, timeout)
}
concurrency, concurrencyErr := tt.hook.EffectiveMaxConcurrency(tt.defaultConcurrency)
if (concurrencyErr != nil) != tt.wantConcurrencyErr {
t.Fatalf("%s: expected concurrency error=%v, got err=%v", tt.name, tt.wantConcurrencyErr, concurrencyErr)
}
if concurrencyErr == nil && concurrency != tt.wantConcurrency {
t.Fatalf("%s: expected concurrency %d, got %d", tt.name, tt.wantConcurrency, concurrency)
}
}
}
var hooksLoadFromFileTests = []struct {
path string
asTemplate bool

View file

@ -36,6 +36,11 @@ type Request struct {
// Treat signature errors as simple validate failures.
AllowSignatureErrors bool
// RealIP is the resolved client IP address. When the request comes through
// a trusted reverse proxy, this is extracted from the configured header
// (e.g. X-Real-Ip). Otherwise it equals RawRequest.RemoteAddr.
RealIP string
}
func (r *Request) ParseJSONPayload() error {

90
realip.go Normal file
View file

@ -0,0 +1,90 @@
package main
import (
"net"
"net/http"
"strings"
)
// parsedTrustedProxies holds the parsed CIDR networks from the --trusted-proxies flag.
var parsedTrustedProxies []*net.IPNet
// initTrustedProxies parses the --trusted-proxies flag value into CIDR networks.
// Must be called after flag.Parse().
func initTrustedProxies() {
if *trustedProxies == "" {
return
}
for _, entry := range strings.Split(*trustedProxies, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
// If it's a plain IP without CIDR notation, add /32 (IPv4) or /128 (IPv6).
if !strings.Contains(entry, "/") {
if strings.Contains(entry, ":") {
entry += "/128"
} else {
entry += "/32"
}
}
_, cidr, err := net.ParseCIDR(entry)
if err != nil {
continue
}
parsedTrustedProxies = append(parsedTrustedProxies, cidr)
}
}
// isTrustedProxy checks whether the given remote address (IP:port) belongs to
// a trusted proxy network.
func isTrustedProxy(remoteAddr string) bool {
if len(parsedTrustedProxies) == 0 {
return false
}
ip := extractIP(remoteAddr)
if ip == nil {
return false
}
for _, cidr := range parsedTrustedProxies {
if cidr.Contains(ip) {
return true
}
}
return false
}
// resolveRealIP determines the real client IP address. If the request comes
// from a trusted proxy and --real-ip-header is configured, the IP is read from
// that header. Otherwise the TCP remote address is used.
func resolveRealIP(r *http.Request) string {
if *realIPHeader != "" && isTrustedProxy(r.RemoteAddr) {
headerVal := strings.TrimSpace(r.Header.Get(*realIPHeader))
if headerVal != "" {
// X-Forwarded-For may contain multiple IPs; take the first one.
if idx := strings.IndexByte(headerVal, ','); idx != -1 {
headerVal = strings.TrimSpace(headerVal[:idx])
}
return headerVal
}
}
return r.RemoteAddr
}
// extractIP parses an IP from an address string that may include a port.
func extractIP(addr string) net.IP {
addr = strings.Trim(addr, " []")
// Try host:port split first.
host, _, err := net.SplitHostPort(addr)
if err == nil {
return net.ParseIP(host)
}
return net.ParseIP(addr)
}

83
scripts/install.ps1 Normal file
View file

@ -0,0 +1,83 @@
[CmdletBinding()]
param(
[string] $Repo = $env:WEBHOOK_REPO,
[string] $InstallDir = $env:WEBHOOK_INSTALL_DIR,
[string] $Version = $env:WEBHOOK_VERSION,
[string] $BinaryName = $env:WEBHOOK_BINARY_NAME
)
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($Repo)) {
$Repo = "xtulnx/webhook"
}
if ([string]::IsNullOrWhiteSpace($InstallDir)) {
$InstallDir = Join-Path $env:LOCALAPPDATA "Programs\webhook"
}
if ([string]::IsNullOrWhiteSpace($Version)) {
$Version = "latest"
}
if ([string]::IsNullOrWhiteSpace($BinaryName)) {
$BinaryName = "webhook.exe"
}
if (-not $BinaryName.EndsWith(".exe", [StringComparison]::OrdinalIgnoreCase)) {
$BinaryName = "$BinaryName.exe"
}
switch ($env:PROCESSOR_ARCHITECTURE) {
"AMD64" { $Arch = "amd64" }
"ARM64" { $Arch = "arm64" }
"x86" { $Arch = "386" }
default {
throw "Unsupported architecture: $env:PROCESSOR_ARCHITECTURE"
}
}
$Asset = "webhook-windows-$Arch.tar.gz"
if ($Version -eq "latest") {
$Url = "https://github.com/$Repo/releases/latest/download/$Asset"
$ChecksumsUrl = "https://github.com/$Repo/releases/latest/download/checksums.txt"
} else {
$Url = "https://github.com/$Repo/releases/download/$Version/$Asset"
$ChecksumsUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt"
}
$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $TempDir | Out-Null
try {
$Archive = Join-Path $TempDir $Asset
Write-Host "Downloading $Repo $Version for windows/$Arch..."
Invoke-WebRequest -Uri $Url -OutFile $Archive
$ChecksumsPath = Join-Path $TempDir "checksums.txt"
Invoke-WebRequest -Uri $ChecksumsUrl -OutFile $ChecksumsPath
$ChecksumLine = Get-Content $ChecksumsPath | Where-Object { $_ -match "\s$([regex]::Escape($Asset))$" } | Select-Object -First 1
if (-not $ChecksumLine) {
throw "Checksum for $Asset not found"
}
$ExpectedHash = ($ChecksumLine -split "\s+")[0].ToLowerInvariant()
$ActualHash = (Get-FileHash -Algorithm SHA256 -Path $Archive).Hash.ToLowerInvariant()
if ($ActualHash -ne $ExpectedHash) {
throw "Checksum mismatch for $Asset"
}
tar -xzf $Archive -C $TempDir
$Source = Join-Path $TempDir "webhook-windows-$Arch\webhook.exe"
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
Copy-Item -Path $Source -Destination (Join-Path $InstallDir $BinaryName) -Force
Write-Host "Installed $BinaryName to $InstallDir"
& (Join-Path $InstallDir $BinaryName) -version
$UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
$Paths = $UserPath -split ";" | Where-Object { $_ }
if ($Paths -notcontains $InstallDir) {
[Environment]::SetEnvironmentVariable("Path", (($Paths + $InstallDir) -join ";"), "User")
Write-Host "Added $InstallDir to the user PATH. Open a new terminal to use webhook directly."
}
} finally {
Remove-Item -Path $TempDir -Recurse -Force -ErrorAction SilentlyContinue
}

121
scripts/install.sh Executable file
View file

@ -0,0 +1,121 @@
#!/bin/sh
set -eu
repo="${WEBHOOK_REPO:-xtulnx/webhook}"
install_dir="${WEBHOOK_INSTALL_DIR:-/usr/local/bin}"
version="${WEBHOOK_VERSION:-latest}"
binary_name="${WEBHOOK_BINARY_NAME:-webhook}"
need() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "error: required command not found: $1" >&2
exit 1
fi
}
detect_os() {
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
case "$os" in
darwin|freebsd|linux|openbsd) printf '%s' "$os" ;;
*) echo "error: unsupported OS: $os" >&2; exit 1 ;;
esac
}
detect_arch() {
arch="$(uname -m)"
case "$arch" in
x86_64|amd64) printf 'amd64' ;;
aarch64|arm64) printf 'arm64' ;;
i386|i686) printf '386' ;;
armv6l|armv7l|arm) printf 'arm' ;;
*) echo "error: unsupported architecture: $arch" >&2; exit 1 ;;
esac
}
download() {
url="$1"
output="$2"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$url" -o "$output"
elif command -v wget >/dev/null 2>&1; then
wget -q "$url" -O "$output"
else
echo "error: curl or wget is required" >&2
exit 1
fi
}
verify_checksum() {
checksums="$1"
archive="$2"
asset="$3"
expected="$(grep " $asset\$" "$checksums" | awk '{print $1}')"
if [ -z "$expected" ]; then
echo "error: checksum for $asset not found" >&2
exit 1
fi
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$archive" | awk '{print $1}')"
elif command -v shasum >/dev/null 2>&1; then
actual="$(shasum -a 256 "$archive" | awk '{print $1}')"
elif command -v sha256 >/dev/null 2>&1; then
actual="$(sha256 -q "$archive")"
else
echo "warning: no SHA256 command found; skipping checksum verification" >&2
return
fi
if [ "$actual" != "$expected" ]; then
echo "error: checksum mismatch for $asset" >&2
exit 1
fi
}
install_binary() {
src="$1"
dst="$2"
mkdir -p "$install_dir" 2>/dev/null || true
if [ -w "$install_dir" ]; then
install -m 0755 "$src" "$dst"
elif command -v sudo >/dev/null 2>&1; then
sudo mkdir -p "$install_dir"
sudo install -m 0755 "$src" "$dst"
else
echo "error: $install_dir is not writable and sudo is not available" >&2
exit 1
fi
}
need uname
need tar
os="$(detect_os)"
arch="$(detect_arch)"
asset="webhook-$os-$arch.tar.gz"
if [ "$version" = "latest" ]; then
url="https://github.com/$repo/releases/latest/download/$asset"
checksums_url="https://github.com/$repo/releases/latest/download/checksums.txt"
else
url="https://github.com/$repo/releases/download/$version/$asset"
checksums_url="https://github.com/$repo/releases/download/$version/checksums.txt"
fi
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT INT TERM
archive="$tmp/$asset"
echo "Downloading $repo $version for $os/$arch..."
download "$url" "$archive"
download "$checksums_url" "$tmp/checksums.txt"
verify_checksum "$tmp/checksums.txt" "$archive" "$asset"
tar -xzf "$archive" -C "$tmp"
install_binary "$tmp/webhook-$os-$arch/webhook" "$install_dir/$binary_name"
echo "Installed $binary_name to $install_dir"
"$install_dir/$binary_name" -version

View file

@ -5,8 +5,10 @@ package main
import (
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
)
func main() {
@ -20,18 +22,46 @@ func main() {
env = append(env, v)
}
}
sort.Strings(env)
if len(env) > 0 {
fmt.Printf("env: %s\n", strings.Join(env, " "))
}
if (len(os.Args) > 1) && (strings.HasPrefix(os.Args[1], "exit=")) {
exit_code_str := os.Args[1][5:]
exit_code, err := strconv.Atoi(exit_code_str)
if err != nil {
fmt.Printf("Exit code %s not an int!", exit_code_str)
os.Exit(-1)
for _, arg := range os.Args[1:] {
switch {
case strings.HasPrefix(arg, "cat-env-file="):
key := strings.TrimPrefix(arg, "cat-env-file=")
path := os.Getenv(key)
if path == "" {
fmt.Printf("File env %s is not set!", key)
os.Exit(-1)
}
content, err := os.ReadFile(path)
if err != nil {
fmt.Printf("Failed to read %s (%s): %v", key, path, err)
os.Exit(-1)
}
fmt.Printf("file: %s=%s\n", key, string(content))
case strings.HasPrefix(arg, "sleep="):
sleepFor := strings.TrimPrefix(arg, "sleep=")
duration, err := time.ParseDuration(sleepFor)
if err != nil {
fmt.Printf("Sleep duration %s is invalid!", sleepFor)
os.Exit(-1)
}
time.Sleep(duration)
fmt.Printf("slept: %s\n", duration)
case strings.HasPrefix(arg, "exit="):
exit_code_str := arg[5:]
exit_code, err := strconv.Atoi(exit_code_str)
if err != nil {
fmt.Printf("Exit code %s not an int!", exit_code_str)
os.Exit(-1)
}
os.Exit(exit_code)
}
os.Exit(exit_code)
}
}

View file

@ -531,6 +531,103 @@
]
}
},
{
"id": "keep-file-environment",
"execute-command": "{{ .Hookecho }}",
"include-command-output-in-response": true,
"keep-file-environment": true,
"pass-arguments-to-command": [
{
"source": "string",
"name": "cat-env-file=HOOK_FILE_PKG"
}
]
},
{
"id": "keep-file-environment-special-name",
"execute-command": "{{ .Hookecho }}",
"include-command-output-in-response": true,
"keep-file-environment": true,
"pass-arguments-to-command": [
{
"source": "string",
"name": "cat-env-file=HOOK_FILE_PKG-NAME"
}
]
},
{
"id": "command-timeout-default",
"execute-command": "{{ .Hookecho }}",
"include-command-output-in-response": true,
"include-command-output-in-response-on-error": true,
"pass-arguments-to-command": [
{
"source": "string",
"name": "sleep=250ms"
}
]
},
{
"id": "command-timeout-hook",
"execute-command": "{{ .Hookecho }}",
"command-timeout": "100ms",
"include-command-output-in-response": true,
"include-command-output-in-response-on-error": true,
"pass-arguments-to-command": [
{
"source": "string",
"name": "sleep=250ms"
}
]
},
{
"id": "command-timeout-unlimited",
"execute-command": "{{ .Hookecho }}",
"command-timeout": "0",
"include-command-output-in-response": true,
"include-command-output-in-response-on-error": true,
"pass-arguments-to-command": [
{
"source": "string",
"name": "sleep=250ms"
}
]
},
{
"id": "max-concurrency-default",
"execute-command": "{{ .Hookecho }}",
"include-command-output-in-response": true,
"pass-arguments-to-command": [
{
"source": "string",
"name": "sleep=250ms"
}
]
},
{
"id": "max-concurrency-hook",
"execute-command": "{{ .Hookecho }}",
"max-concurrency": 1,
"include-command-output-in-response": true,
"pass-arguments-to-command": [
{
"source": "string",
"name": "sleep=250ms"
}
]
},
{
"id": "max-concurrency-unlimited",
"execute-command": "{{ .Hookecho }}",
"max-concurrency": 0,
"include-command-output-in-response": true,
"pass-arguments-to-command": [
{
"source": "string",
"name": "sleep=250ms"
}
]
},
{
"id": "empty-payload-signature",
"execute-command": "{{ .Hookecho }}",

View file

@ -302,6 +302,71 @@
type: value
value: 1
- id: keep-file-environment
execute-command: '{{ .Hookecho }}'
include-command-output-in-response: true
keep-file-environment: true
pass-arguments-to-command:
- source: string
name: cat-env-file=HOOK_FILE_PKG
- id: keep-file-environment-special-name
execute-command: '{{ .Hookecho }}'
include-command-output-in-response: true
keep-file-environment: true
pass-arguments-to-command:
- source: string
name: cat-env-file=HOOK_FILE_PKG-NAME
- id: command-timeout-default
execute-command: '{{ .Hookecho }}'
include-command-output-in-response: true
include-command-output-in-response-on-error: true
pass-arguments-to-command:
- source: string
name: sleep=250ms
- id: command-timeout-hook
execute-command: '{{ .Hookecho }}'
command-timeout: 100ms
include-command-output-in-response: true
include-command-output-in-response-on-error: true
pass-arguments-to-command:
- source: string
name: sleep=250ms
- id: command-timeout-unlimited
execute-command: '{{ .Hookecho }}'
command-timeout: '0'
include-command-output-in-response: true
include-command-output-in-response-on-error: true
pass-arguments-to-command:
- source: string
name: sleep=250ms
- id: max-concurrency-default
execute-command: '{{ .Hookecho }}'
include-command-output-in-response: true
pass-arguments-to-command:
- source: string
name: sleep=250ms
- id: max-concurrency-hook
execute-command: '{{ .Hookecho }}'
max-concurrency: 1
include-command-output-in-response: true
pass-arguments-to-command:
- source: string
name: sleep=250ms
- id: max-concurrency-unlimited
execute-command: '{{ .Hookecho }}'
max-concurrency: 0
include-command-output-in-response: true
pass-arguments-to-command:
- source: string
name: sleep=250ms
- id: empty-payload-signature
include-command-output-in-response: true
execute-command: '{{ .Hookecho }}'

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net"
@ -24,9 +25,7 @@ import (
"github.com/gorilla/mux"
)
const (
version = "2.8.3"
)
var version = "2.8.3"
var (
ip = flag.String("ip", "0.0.0.0", "ip the webhook should serve hooks on")
@ -50,6 +49,8 @@ var (
maxMultipartMem = flag.Int64("max-multipart-mem", 1<<20, "maximum memory in bytes for parsing multipart form data before disk caching")
httpMethods = flag.String("http-methods", "", `set default allowed HTTP methods (ie. "POST"); separate methods with comma`)
pidPath = flag.String("pidfile", "", "create PID file at the given path")
realIPHeader = flag.String("real-ip-header", "", "header to extract real client IP from when behind a reverse proxy (e.g. X-Real-Ip)")
trustedProxies = flag.String("trusted-proxies", "", "comma-separated list of trusted proxy IPs or CIDRs; required for real-ip-header to take effect")
responseHeaders hook.ResponseHeaders
hooksFiles hook.HooksFiles
@ -66,9 +67,13 @@ var (
)
func matchLoadedHook(id string) *hook.Hook {
for _, hooks := range loadedHooksFromFiles {
if hook := hooks.Match(id); hook != nil {
return hook
loadedHooksMu.RLock()
defer loadedHooksMu.RUnlock()
for _, hooksInFile := range loadedHooksFromFiles {
if matched := hooksInFile.Match(id); matched != nil {
cloned := cloneHook(*matched)
return &cloned
}
}
@ -76,9 +81,12 @@ func matchLoadedHook(id string) *hook.Hook {
}
func lenLoadedHooks() int {
loadedHooksMu.RLock()
defer loadedHooksMu.RUnlock()
sum := 0
for _, hooks := range loadedHooksFromFiles {
sum += len(hooks)
for _, hooksInFile := range loadedHooksFromFiles {
sum += len(hooksInFile)
}
return sum
@ -91,8 +99,24 @@ func main() {
// register platform-specific flags
platformFlags()
// Extract -config / -c from os.Args before flag.Parse() sees it.
configFile = extractConfigFlag()
if configFile != "" {
if err := loadConfigFile(configFile); err != nil {
fmt.Println("error:", err)
os.Exit(1)
}
}
flag.Parse()
initTrustedProxies()
if err := initExecutionSettings(); err != nil {
fmt.Println("error:", err)
os.Exit(1)
}
if *justDisplayVersion {
fmt.Println("webhook version " + version)
os.Exit(0)
@ -120,6 +144,11 @@ func main() {
hooksFiles = append(hooksFiles, "hooks.json")
}
if err := initAdmin(); err != nil {
fmt.Println("error:", err)
os.Exit(1)
}
// logQueue is a queue for log messages encountered during startup. We need
// to queue the messages so that we can handle any privilege dropping and
// log file opening prior to writing our first log message.
@ -209,27 +238,34 @@ func main() {
if err != nil {
log.Printf("couldn't load hooks from file! %+v\n", err)
} else {
loadedHooksMu.Lock()
candidate := cloneLoadedHooksMapLocked()
candidate[hooksFilePath] = cloneHooks(newHooks)
if err := validateUniqueHookIDs(candidate); err != nil {
loadedHooksMu.Unlock()
log.Fatalf("error: %s\nplease check your hooks files for duplicate hook ids!\n", err)
}
log.Printf("found %d hook(s) in file\n", len(newHooks))
for _, hook := range newHooks {
if matchLoadedHook(hook.ID) != nil {
log.Fatalf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!\n", hook.ID)
}
log.Printf("\tloaded: %s\n", hook.ID)
for _, currentHook := range newHooks {
log.Printf("\tloaded: %s\n", currentHook.ID)
}
loadedHooksFromFiles[hooksFilePath] = newHooks
loadedHooksMu.Unlock()
}
}
loadedHooksMu.Lock()
newHooksFiles := hooksFiles[:0]
for _, filePath := range hooksFiles {
if _, ok := loadedHooksFromFiles[filePath]; ok {
newHooksFiles = append(newHooksFiles, filePath)
}
}
hooksFiles = newHooksFiles
loadedHooksMu.Unlock()
if !*verbose && !*noPanic && lenLoadedHooks() == 0 {
log.SetOutput(os.Stdout)
@ -245,7 +281,7 @@ func main() {
}
defer watcher.Close()
for _, hooksFilePath := range hooksFiles {
for _, hooksFilePath := range hooksFilesSnapshot() {
// set up file watcher
log.Printf("setting up file watcher for %s\n", hooksFilePath)
@ -285,6 +321,7 @@ func main() {
fmt.Fprint(w, "OK")
})
registerAdminRoutes(r)
r.HandleFunc(hooksURL, hookHandler)
// Create common HTTP server settings
@ -295,6 +332,9 @@ func main() {
// Serve HTTP
if !*secure {
log.Printf("serving hooks on http://%s%s", addr, makeHumanPattern(hooksURLPrefix))
if *adminEnabled {
log.Printf("serving admin on http://%s%s", addr, currentAdminAuth.basePath)
}
log.Print(svr.Serve(ln))
return
@ -310,6 +350,9 @@ func main() {
svr.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) // disable http/2
log.Printf("serving hooks on https://%s%s", addr, makeHumanPattern(hooksURLPrefix))
if *adminEnabled {
log.Printf("serving admin on https://%s%s", addr, currentAdminAuth.basePath)
}
log.Print(svr.ServeTLS(ln, *cert, *key))
}
@ -317,9 +360,10 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
req := &hook.Request{
ID: middleware.GetReqID(r.Context()),
RawRequest: r,
RealIP: resolveRealIP(r),
}
log.Printf("[%s] incoming HTTP %s request from %s\n", req.ID, r.Method, r.RemoteAddr)
log.Printf("[%s] incoming HTTP %s request from %s\n", req.ID, r.Method, req.RealIP)
// TODO: rename this to avoid confusion with Request.ID
id := mux.Vars(r)["id"]
@ -473,6 +517,9 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("[%s] error parsing JSON payload file: %+v\n", req.ID, err)
}
if err := f.Close(); err != nil {
log.Printf("[%s] error closing multipart form file: %+v\n", req.ID, err)
}
if req.Payload == nil {
req.Payload = make(map[string]interface{})
@ -524,12 +571,14 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
response, err := handleHook(matchedHook, req)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
if matchedHook.CaptureCommandOutputOnError {
status, message := hookExecutionErrorStatus(err)
if matchedHook.CaptureCommandOutputOnError && response != "" {
w.WriteHeader(status)
fmt.Fprint(w, response)
} else {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprint(w, "Error occurred while executing the hook's command. Please check your logs for more details.")
w.WriteHeader(status)
fmt.Fprint(w, message)
}
} else {
// Check if a success return code is configured for the hook
@ -539,7 +588,13 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, response)
}
} else {
go handleHook(matchedHook, req)
if err := handleHookAsync(matchedHook, req); err != nil {
status, message := hookExecutionErrorStatus(err)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(status)
fmt.Fprint(w, message)
return
}
// Check if a success return code is configured for the hook
if matchedHook.SuccessHttpResponseCode != 0 {
@ -562,7 +617,9 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Hook rules were not satisfied.")
}
func handleHook(h *hook.Hook, r *hook.Request) (string, error) {
func executeHook(h *hook.Hook, r *hook.Request, waitForCommand bool, release func()) (string, error) {
defer release()
var errors []error
// check the command exists
@ -586,7 +643,19 @@ func handleHook(h *hook.Hook, r *hook.Request) (string, error) {
return "", err
}
cmd := exec.Command(cmdPath)
ctx, cancel, useContext, err := hookExecutionContext(h, r, waitForCommand)
if err != nil {
log.Printf("[%s] error preparing command execution: %s", r.ID, err)
return "", err
}
defer cancel()
var cmd *exec.Cmd
if useContext {
cmd = exec.CommandContext(ctx, cmdPath)
} else {
cmd = exec.Command(cmdPath)
}
cmd.Dir = h.CommandWorkingDirectory
cmd.Args, errors = h.ExtractCommandArguments(r)
@ -627,6 +696,52 @@ func handleHook(h *hook.Hook, r *hook.Request) (string, error) {
envs = append(envs, files[i].EnvName+"="+tmpfile.Name())
}
if h.KeepFileEnvironment && r.RawRequest != nil && r.RawRequest.MultipartForm != nil {
for k, v := range r.RawRequest.MultipartForm.File {
env_name := hook.EnvNamespace + "FILE_" + strings.ToUpper(k)
f, err := v[0].Open()
if err != nil {
log.Printf("[%s] error open form %s file [%s]", r.ID, k, err)
continue
}
if f1, ok := f.(*os.File); ok {
log.Printf("[%s] temporary file %s", r.ID, f1.Name())
_ = f1.Close()
files = append(files, hook.FileParameter{File: f1, EnvName: env_name})
envs = append(envs,
env_name+"="+f1.Name(),
hook.EnvNamespace+"FILENAME_"+strings.ToUpper(k)+"="+v[0].Filename,
)
continue
}
tmpfile, err := os.CreateTemp("", ".hook-"+r.ID+"-"+k+"-*")
if err != nil {
_ = f.Close()
log.Printf("[%s] error creating temp file [%s]", r.ID, err)
continue
}
log.Printf("[%s] writing env %s file %s", r.ID, env_name, tmpfile.Name())
if _, err = io.Copy(tmpfile, f); err != nil {
log.Printf("[%s] error writing file %s [%s]", r.ID, tmpfile.Name(), err)
_ = f.Close()
_ = tmpfile.Close()
_ = os.Remove(tmpfile.Name())
continue
}
if err := tmpfile.Close(); err != nil {
log.Printf("[%s] error closing file %s [%s]", r.ID, tmpfile.Name(), err)
_ = os.Remove(tmpfile.Name())
continue
}
_ = f.Close()
files = append(files, hook.FileParameter{File: tmpfile, EnvName: env_name})
envs = append(envs,
env_name+"="+tmpfile.Name(),
hook.EnvNamespace+"FILENAME_"+strings.ToUpper(k)+"="+v[0].Filename,
)
}
}
cmd.Env = append(os.Environ(), envs...)
log.Printf("[%s] executing %s (%s) with arguments %q and environment %s using %s as cwd\n", r.ID, h.ExecuteCommand, cmd.Path, cmd.Args, envs, cmd.Dir)
@ -643,11 +758,16 @@ func handleHook(h *hook.Hook, r *hook.Request) (string, error) {
if files[i].File != nil {
log.Printf("[%s] removing file %s\n", r.ID, files[i].File.Name())
err := os.Remove(files[i].File.Name())
if err != nil {
if err != nil && !os.IsNotExist(err) {
log.Printf("[%s] error removing file %s [%s]", r.ID, files[i].File.Name(), err)
}
}
}
if r.RawRequest != nil && r.RawRequest.MultipartForm != nil {
if err := r.RawRequest.MultipartForm.RemoveAll(); err != nil && !os.IsNotExist(err) {
log.Printf("[%s] error removing multipart form temp files [%s]", r.ID, err)
}
}
log.Printf("[%s] finished handling %s\n", r.ID, h.ID)
@ -675,28 +795,21 @@ func reloadHooks(hooksFilePath string) {
if err != nil {
log.Printf("couldn't load hooks from file! %+v\n", err)
} else {
seenHooksIds := make(map[string]bool)
loadedHooksMu.Lock()
defer loadedHooksMu.Unlock()
candidate := cloneLoadedHooksMapLocked()
candidate[hooksFilePath] = cloneHooks(hooksInFile)
if err := validateUniqueHookIDs(candidate); err != nil {
log.Printf("error: %s", err)
log.Println("reverting hooks back to the previous configuration")
return
}
log.Printf("found %d hook(s) in file\n", len(hooksInFile))
for _, hook := range hooksInFile {
wasHookIDAlreadyLoaded := false
for _, loadedHook := range loadedHooksFromFiles[hooksFilePath] {
if loadedHook.ID == hook.ID {
wasHookIDAlreadyLoaded = true
break
}
}
if (matchLoadedHook(hook.ID) != nil && !wasHookIDAlreadyLoaded) || seenHooksIds[hook.ID] {
log.Printf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!", hook.ID)
log.Println("reverting hooks back to the previous configuration")
return
}
seenHooksIds[hook.ID] = true
log.Printf("\tloaded: %s\n", hook.ID)
for _, currentHook := range hooksInFile {
log.Printf("\tloaded: %s\n", currentHook.ID)
}
loadedHooksFromFiles[hooksFilePath] = hooksInFile
@ -704,14 +817,16 @@ func reloadHooks(hooksFilePath string) {
}
func reloadAllHooks() {
for _, hooksFilePath := range hooksFiles {
for _, hooksFilePath := range hooksFilesSnapshot() {
reloadHooks(hooksFilePath)
}
}
func removeHooks(hooksFilePath string) {
for _, hook := range loadedHooksFromFiles[hooksFilePath] {
log.Printf("\tremoving: %s\n", hook.ID)
loadedHooksMu.Lock()
for _, currentHook := range loadedHooksFromFiles[hooksFilePath] {
log.Printf("\tremoving: %s\n", currentHook.ID)
}
newHooksFiles := hooksFiles[:0]
@ -729,7 +844,14 @@ func removeHooks(hooksFilePath string) {
log.Printf("removed %d hook(s) that were loaded from file %s\n", removedHooksCount, hooksFilePath)
if !*verbose && !*noPanic && lenLoadedHooks() == 0 {
remainingHooksCount := 0
for _, hooksInFile := range loadedHooksFromFiles {
remainingHooksCount += len(hooksInFile)
}
loadedHooksMu.Unlock()
if !*verbose && !*noPanic && remainingHooksCount == 0 {
log.SetOutput(os.Stdout)
log.Fatalln("couldn't load any hooks from file!\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to run without the hooks, either use -verbose flag, or -nopanic")
}

179
webhook.yaml.example Normal file
View file

@ -0,0 +1,179 @@
# Webhook 配置文件
# 用法: webhook -c /etc/webhook/webhook.yaml
#
# 支持所有命令行参数。
# 列表类参数hooks、header使用 YAML 数组语法。
# 布尔类参数使用 true/false。
# 命令行参数优先级高于本配置文件。
# ============================================================
# 网络设置
# ============================================================
# 监听地址,默认 0.0.0.0(监听所有网卡)
ip: 0.0.0.0
# 监听端口,默认 9000
port: 1987
# URL 前缀,最终 hook 地址为 http://host:port/PREFIX/hook-id
# 默认值为 "hooks"
urlprefix: wh
# 限制允许的 HTTP 方法,多个方法用逗号分隔(如 "POST,PUT"
# 留空表示允许所有方法
# http-methods: "POST"
# ============================================================
# 反向代理 / 真实 IP
# ============================================================
# 当 webhook 部署在 Nginx 等反向代理后面时RemoteAddr 是代理的内网 IP
# 需要通过以下两个参数配合,让 ip-whitelist 规则能正确识别真实客户端 IP。
# 从哪个请求头获取真实客户端 IP
# 常见值: "X-Real-Ip"Nginx proxy_set_header X-Real-IP
# "X-Forwarded-For"(取第一个 IP
# 留空表示不启用,直接使用 RemoteAddr
# real-ip-header: "X-Real-Ip"
# 可信代理 IP 或 CIDR 列表,逗号分隔
# 只有当请求来自这些地址时,才会从 real-ip-header 中提取真实 IP
# 防止客户端伪造 header 绕过 IP 白名单
# 示例: "127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
# trusted-proxies: "127.0.0.1,10.0.0.0/8"
# ============================================================
# Hook 定义文件
# ============================================================
# hook 定义文件路径,支持 JSON 和 YAML 格式
# 可指定多个文件,每个文件中的 hook id 不能重复
hooks:
- /etc/webhook/admin.yaml
- /etc/webhook/hooks.yaml
# ============================================================
# 运行行为
# ============================================================
# 当无法加载任何 hook 时不要 panic 退出(需配合非 verbose 模式)
# 默认 false
nopanic: true
# 监视 hook 文件变化,自动热重载
# 默认 false
hotreload: true
# 将 hook 文件作为 Go template 解析
# 默认 false
# template: false
# multipart 表单解析时的最大内存(字节),超出部分写入磁盘临时文件
# 默认 1048576 (1MB)
# max-multipart-mem: 1048576
# ============================================================
# 日志设置
# ============================================================
# 启用详细日志输出
# 默认 false
# verbose: true
# 启用调试输出(会打印完整的请求/响应内容,隐含 verbose=true
# 默认 false
# debug: false
# 日志输出到文件(设置后隐含 verbose=true
# 留空表示输出到 stdout
# logfile: /var/log/webhook.log
# ============================================================
# 自定义响应头
# ============================================================
# 所有 hook 响应都会附带这些 header格式为 "Name=Value"
# header:
# - "X-Custom-Header=value"
# - "Access-Control-Allow-Origin=*"
# ============================================================
# Request ID
# ============================================================
# 使用客户端传入的 X-Request-Id 头作为请求 ID用于日志追踪
# 默认 false
# x-request-id: false
# 截断 X-Request-Id 的最大长度0 表示不限制
# x-request-id-limit: 0
# ============================================================
# TLS / HTTPS 设置
# ============================================================
# 启用 HTTPS 模式
# 默认 false
# secure: false
# TLS 证书文件路径
# cert: /etc/webhook/cert.pem
# TLS 私钥文件路径
# key: /etc/webhook/key.pem
# TLS 最低版本,可选 "1.0", "1.1", "1.2", "1.3"
# 默认 "1.2"
# tls-min-version: "1.2"
# TLS 加密套件,逗号分隔。留空使用 Go 默认值
# 可通过 webhook --list-cipher-suites 查看可用列表
# cipher-suites: ""
# ============================================================
# Admin 管理界面
# ============================================================
# 启用 Admin UI 和 API用于在线管理 hooks
# 默认 false
# admin: false
# Admin 界面的 URL 前缀,最终地址为 http://host:port/PREFIX/
# 默认 "admin"
# admin-path: admin
# Admin 登录使用的 TOTP 密钥Base32 编码)
# 可用 Google Authenticator 等 APP 扫码登录
# admin-totp-secret: ""
# 用于签发 Admin JWT 会话令牌的密钥
# admin-jwt-secret: ""
# Admin JWT 会话有效期,支持 Go duration 格式(如 12h、30m、720h
# 默认 "12h"
# admin-session-ttl: "12h"
# ============================================================
# Unix Socket仅 Linux/macOS
# ============================================================
# 使用 Unix socket 代替 IP:Port 监听
# 设置后 ip 和 port 选项将被忽略
# socket: /tmp/webhook.sock
# ============================================================
# 权限降级(仅 Linux/macOS
# ============================================================
# 打开监听端口后切换到指定的用户/组 ID 运行
# setuid 和 setgid 必须同时设置,不能与 socket 同时使用
# setuid: 1000
# setgid: 1000
# ============================================================
# PID 文件
# ============================================================
# 创建 PID 文件,用于进程管理
# pidfile: /var/run/webhook.pid

View file

@ -5,6 +5,7 @@ import (
"fmt"
"io/ioutil"
"log"
"mime/multipart"
"net"
"net/http"
"os"
@ -332,6 +333,120 @@ func killAndWait(cmd *exec.Cmd) {
cmd.Wait()
}
func startWebhookServer(t *testing.T, webhookBin, configPath string, extraArgs ...string) (*exec.Cmd, *buffer, string) {
t.Helper()
ip, port := serverAddress(t)
args := []string{
fmt.Sprintf("-hooks=%s", configPath),
fmt.Sprintf("-ip=%s", ip),
fmt.Sprintf("-port=%s", port),
"-debug",
}
args = append(args, extraArgs...)
logs := &buffer{}
cmd := exec.Command(webhookBin, args...)
cmd.Stderr = logs
cmd.Env = webhookEnv()
cmd.Args[0] = "webhook"
if err := cmd.Start(); err != nil {
t.Fatalf("failed to start webhook: %s", err)
}
waitForServerReady(t, net.JoinHostPort(ip, port), &http.Client{})
return cmd, logs, "http://" + net.JoinHostPort(ip, port)
}
func doJSONHookRequestResult(baseURL, hookID string) (int, string, error) {
req, err := http.NewRequest(http.MethodPost, baseURL+"/hooks/"+hookID, bytes.NewBufferString(`{}`))
if err != nil {
return 0, "", err
}
req.Header.Set("Content-Type", "application/json")
req.ContentLength = int64(len(`{}`))
client := &http.Client{}
res, err := client.Do(req)
if err != nil {
return 0, "", err
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return 0, "", err
}
return res.StatusCode, string(body), nil
}
func doJSONHookRequest(t *testing.T, baseURL, hookID string) (int, string) {
t.Helper()
status, body, err := doJSONHookRequestResult(baseURL, hookID)
if err != nil {
t.Fatalf("failed to execute request: %v", err)
}
return status, body
}
func doMultipartHookRequest(t *testing.T, baseURL, hookID, fieldName, fileName string, data []byte) (int, string) {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile(fieldName, fileName)
if err != nil {
t.Fatalf("failed to create multipart form file: %v", err)
}
if _, err := part.Write(data); err != nil {
t.Fatalf("failed to write multipart payload: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
req, err := http.NewRequest(http.MethodPost, baseURL+"/hooks/"+hookID, bytes.NewReader(body.Bytes()))
if err != nil {
t.Fatalf("failed to create multipart request: %v", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.ContentLength = int64(body.Len())
client := &http.Client{}
res, err := client.Do(req)
if err != nil {
t.Fatalf("failed to execute multipart request: %v", err)
}
defer res.Body.Close()
respBody, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatalf("failed to read multipart response body: %v", err)
}
return res.StatusCode, string(respBody)
}
func waitForBufferContains(t *testing.T, b *buffer, needle string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if strings.Contains(b.String(), needle) {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("buffer did not contain %q within %v; got:\n%s", needle, timeout, b.String())
}
// webhookEnv returns the process environment without any existing hook
// namespace variables.
func webhookEnv() (env []string) {
@ -344,6 +459,273 @@ func webhookEnv() (env []string) {
return
}
func TestWebhookKeepFileEnvironment(t *testing.T) {
hookecho, cleanupHookecho := buildHookecho(t)
defer cleanupHookecho()
webhookBin, cleanupWebhook := buildWebhook(t)
defer cleanupWebhook()
tests := []struct {
name string
id string
fieldName string
fileName string
fileContent string
fileEnv string
nameEnv string
}{
{
name: "default field name",
id: "keep-file-environment",
fieldName: "pkg",
fileName: "pkg.tar.gz",
fileContent: "payload-data",
fileEnv: "HOOK_FILE_PKG",
nameEnv: "HOOK_FILENAME_PKG",
},
{
name: "special field name",
id: "keep-file-environment-special-name",
fieldName: "pkg-name",
fileName: "pkg-name.txt",
fileContent: "special-data",
fileEnv: "HOOK_FILE_PKG-NAME",
nameEnv: "HOOK_FILENAME_PKG-NAME",
},
}
for _, hookTmpl := range []string{"test/hooks.json.tmpl", "test/hooks.yaml.tmpl"} {
configPath, cleanupConfig := genConfig(t, hookecho, hookTmpl)
defer cleanupConfig()
for _, tt := range tests {
t.Run(tt.name+"@"+hookTmpl, func(t *testing.T) {
cmd, _, baseURL := startWebhookServer(t, webhookBin, configPath)
defer killAndWait(cmd)
status, body := doMultipartHookRequest(t, baseURL, tt.id, tt.fieldName, tt.fileName, []byte(tt.fileContent))
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", status, body)
}
if !strings.Contains(body, "arg: cat-env-file="+tt.fileEnv) {
t.Fatalf("response did not contain file env arg: %s", body)
}
if !strings.Contains(body, tt.nameEnv+"="+tt.fileName) {
t.Fatalf("response did not contain filename env %s=%s: %s", tt.nameEnv, tt.fileName, body)
}
if !strings.Contains(body, "file: "+tt.fileEnv+"="+tt.fileContent) {
t.Fatalf("response did not contain file contents for %s: %s", tt.fileEnv, body)
}
match := regexp.MustCompile(`(?m)^env: .*` + regexp.QuoteMeta(tt.fileEnv) + `=([^ ]+)`).FindStringSubmatch(body)
if len(match) != 2 {
t.Fatalf("could not extract temp file path from response: %s", body)
}
if _, err := os.Stat(match[1]); !os.IsNotExist(err) {
t.Fatalf("expected temp file %s to be removed after execution, stat err=%v", match[1], err)
}
})
}
}
}
func TestWebhookCommandTimeout(t *testing.T) {
hookecho, cleanupHookecho := buildHookecho(t)
defer cleanupHookecho()
webhookBin, cleanupWebhook := buildWebhook(t)
defer cleanupWebhook()
tests := []struct {
name string
id string
extraArgs []string
wantStatus int
wantContains []string
wantNotContains []string
minElapsed time.Duration
maxElapsed time.Duration
}{
{
name: "global default timeout",
id: "command-timeout-default",
extraArgs: []string{"-command-timeout=100ms"},
wantStatus: http.StatusInternalServerError,
wantContains: nil,
wantNotContains: []string{"slept:"},
maxElapsed: 220 * time.Millisecond,
},
{
name: "hook timeout",
id: "command-timeout-hook",
wantStatus: http.StatusInternalServerError,
wantContains: nil,
wantNotContains: []string{"slept:"},
maxElapsed: 220 * time.Millisecond,
},
{
name: "hook timeout override disabled",
id: "command-timeout-unlimited",
extraArgs: []string{"-command-timeout=100ms"},
wantStatus: http.StatusOK,
wantContains: []string{"arg: sleep=250ms", "slept: 250ms"},
wantNotContains: nil,
minElapsed: 220 * time.Millisecond,
},
}
for _, hookTmpl := range []string{"test/hooks.json.tmpl", "test/hooks.yaml.tmpl"} {
configPath, cleanupConfig := genConfig(t, hookecho, hookTmpl)
defer cleanupConfig()
for _, tt := range tests {
t.Run(tt.name+"@"+hookTmpl, func(t *testing.T) {
cmd, _, baseURL := startWebhookServer(t, webhookBin, configPath, tt.extraArgs...)
defer killAndWait(cmd)
start := time.Now()
status, body := doJSONHookRequest(t, baseURL, tt.id)
elapsed := time.Since(start)
if status != tt.wantStatus {
t.Fatalf("expected status %d, got %d: %s", tt.wantStatus, status, body)
}
for _, want := range tt.wantContains {
if !strings.Contains(body, want) {
t.Fatalf("response missing %q: %s", want, body)
}
}
for _, notWant := range tt.wantNotContains {
if strings.Contains(body, notWant) {
t.Fatalf("response unexpectedly contained %q: %s", notWant, body)
}
}
if tt.minElapsed > 0 && elapsed < tt.minElapsed {
t.Fatalf("request completed too quickly: got %v, want >= %v", elapsed, tt.minElapsed)
}
if tt.maxElapsed > 0 && elapsed > tt.maxElapsed {
t.Fatalf("request completed too slowly: got %v, want <= %v", elapsed, tt.maxElapsed)
}
})
}
}
}
func TestWebhookMaxConcurrency(t *testing.T) {
hookecho, cleanupHookecho := buildHookecho(t)
defer cleanupHookecho()
webhookBin, cleanupWebhook := buildWebhook(t)
defer cleanupWebhook()
limitedTests := []struct {
name string
id string
extraArgs []string
limitNeedle string
}{
{
name: "global default limit",
id: "max-concurrency-default",
extraArgs: []string{"-max-concurrency=1"},
limitNeedle: "Hook concurrency limit exceeded. Please try again later.",
},
{
name: "hook limit",
id: "max-concurrency-hook",
extraArgs: nil,
limitNeedle: "Hook concurrency limit exceeded. Please try again later.",
},
}
for _, hookTmpl := range []string{"test/hooks.json.tmpl", "test/hooks.yaml.tmpl"} {
configPath, cleanupConfig := genConfig(t, hookecho, hookTmpl)
defer cleanupConfig()
for _, tt := range limitedTests {
t.Run(tt.name+"@"+hookTmpl, func(t *testing.T) {
cmd, logs, baseURL := startWebhookServer(t, webhookBin, configPath, tt.extraArgs...)
defer killAndWait(cmd)
type result struct {
status int
body string
err error
}
firstDone := make(chan result, 1)
go func() {
status, body, err := doJSONHookRequestResult(baseURL, tt.id)
firstDone <- result{status: status, body: body, err: err}
}()
waitForBufferContains(t, logs, "executing", time.Second)
secondStatus, secondBody := doJSONHookRequest(t, baseURL, tt.id)
firstResult := <-firstDone
if firstResult.err != nil {
t.Fatalf("first request failed: %v", firstResult.err)
}
if secondStatus != http.StatusServiceUnavailable {
t.Fatalf("expected second request to return 503, got %d: %s", secondStatus, secondBody)
}
if !strings.Contains(secondBody, tt.limitNeedle) {
t.Fatalf("expected second response to contain %q: %s", tt.limitNeedle, secondBody)
}
if firstResult.status != http.StatusOK {
t.Fatalf("expected first request to succeed, got %d: %s", firstResult.status, firstResult.body)
}
if !strings.Contains(firstResult.body, "slept: 250ms") {
t.Fatalf("expected first response to contain sleep output: %s", firstResult.body)
}
})
}
t.Run("hook limit override disabled@"+hookTmpl, func(t *testing.T) {
cmd, _, baseURL := startWebhookServer(t, webhookBin, configPath, "-max-concurrency=1")
defer killAndWait(cmd)
type result struct {
status int
body string
err error
}
results := make(chan result, 2)
start := time.Now()
for i := 0; i < 2; i++ {
go func() {
status, body, err := doJSONHookRequestResult(baseURL, "max-concurrency-unlimited")
results <- result{status: status, body: body, err: err}
}()
}
first := <-results
second := <-results
elapsed := time.Since(start)
for _, result := range []result{first, second} {
if result.err != nil {
t.Fatalf("request failed: %v", result.err)
}
if result.status != http.StatusOK {
t.Fatalf("expected request to succeed, got %d: %s", result.status, result.body)
}
if !strings.Contains(result.body, "slept: 250ms") {
t.Fatalf("expected response to contain sleep output: %s", result.body)
}
}
if elapsed > 450*time.Millisecond {
t.Fatalf("expected unlimited override to allow parallel execution, took %v", elapsed)
}
})
}
}
type hookHandlerTest struct {
desc string
id string