Browse Source

Initial mirror from https://github.com/ncarlier/webhookd.git

This repository was automatically mirrored.
mitch donaberger 3 months ago
commit
17bc957d93
97 changed files with 4906 additions and 0 deletions
  1. 1 0
      .dockerignore
  2. 101 0
      .github/workflows/build.yml
  3. 6 0
      .gitignore
  4. 3 0
      .gitmodules
  5. 81 0
      CONTRIBUTING.md
  6. 99 0
      Dockerfile
  7. 21 0
      LICENSE
  8. 110 0
      Makefile
  9. 435 0
      README.md
  10. BIN
      demo.gif
  11. 14 0
      docker-compose.yml
  12. 21 0
      docker-entrypoint.sh
  13. 68 0
      etc/default/webhookd.env
  14. 14 0
      go.mod
  15. 18 0
      go.sum
  16. 45 0
      install.sh
  17. 93 0
      main.go
  18. 32 0
      pkg/api/healthz.go
  19. 33 0
      pkg/api/helper.go
  20. 276 0
      pkg/api/index.go
  21. 28 0
      pkg/api/info.go
  22. 23 0
      pkg/api/router.go
  23. 70 0
      pkg/api/routes.go
  24. 19 0
      pkg/api/static.go
  25. 30 0
      pkg/api/test/helper_test.go
  26. 29 0
      pkg/api/types.go
  27. 25 0
      pkg/api/varz.go
  28. 68 0
      pkg/assert/assert.go
  29. 11 0
      pkg/auth/authenticator.go
  30. 81 0
      pkg/auth/htpasswd-file.go
  31. 27 0
      pkg/auth/test/htpasswd-file_test.go
  32. 2 0
      pkg/auth/test/test.htpasswd
  33. 63 0
      pkg/config/config.go
  34. 54 0
      pkg/config/deprecated.go
  35. 121 0
      pkg/config/flag/bind.go
  36. 42 0
      pkg/config/flag/test/bind_test.go
  37. 40 0
      pkg/config/flag/types.go
  38. 151 0
      pkg/helper/header/header.go
  39. 64 0
      pkg/helper/negociate.go
  40. 93 0
      pkg/helper/snake.go
  41. 25 0
      pkg/helper/test/snake_test.go
  42. 14 0
      pkg/helper/values.go
  43. 24 0
      pkg/hook/helper.go
  44. 297 0
      pkg/hook/job.go
  45. 24 0
      pkg/hook/logs.go
  46. 32 0
      pkg/hook/test/helper_test.go
  47. 94 0
      pkg/hook/test/job_test.go
  48. 6 0
      pkg/hook/test/test_error.sh
  49. 19 0
      pkg/hook/test/test_simple.sh
  50. 12 0
      pkg/hook/test/test_timeout.sh
  51. 26 0
      pkg/hook/types.go
  52. 48 0
      pkg/logger/logger.go
  53. 35 0
      pkg/metric/metric.go
  54. 26 0
      pkg/middleware/authn.go
  55. 18 0
      pkg/middleware/cors.go
  56. 13 0
      pkg/middleware/hsts.go
  57. 92 0
      pkg/middleware/logger.go
  58. 24 0
      pkg/middleware/methods.go
  59. 26 0
      pkg/middleware/signature.go
  60. 70 0
      pkg/middleware/signature/ed25519-signature.go
  61. 28 0
      pkg/middleware/signature/http-signature.go
  62. 47 0
      pkg/middleware/signature/test/ed5519-signature_test.go
  63. 47 0
      pkg/middleware/signature/test/http-signature_test.go
  64. 21 0
      pkg/middleware/tracing.go
  65. 19 0
      pkg/middleware/types.go
  66. 27 0
      pkg/middleware/xff.go
  67. 8 0
      pkg/notification/all/all.go
  68. 75 0
      pkg/notification/http/http_notifier.go
  69. 28 0
      pkg/notification/notifier.go
  70. 33 0
      pkg/notification/registry.go
  71. 146 0
      pkg/notification/smtp/smtp_notifier.go
  72. 10 0
      pkg/notification/types.go
  73. 77 0
      pkg/server/server.go
  74. 31 0
      pkg/truststore/p12_truststore.go
  75. 56 0
      pkg/truststore/pem_truststore.go
  76. 21 0
      pkg/truststore/test/p12_truststore_test.go
  77. 55 0
      pkg/truststore/test/pem_truststore_test.go
  78. BIN
      pkg/truststore/test/test.p12
  79. 45 0
      pkg/truststore/truststore.go
  80. 55 0
      pkg/version/version.go
  81. 35 0
      pkg/worker/dispatcher.go
  82. 23 0
      pkg/worker/types.go
  83. 68 0
      pkg/worker/worker.go
  84. 10 0
      scripts/async.sh
  85. 15 0
      scripts/echo.sh
  86. 19 0
      scripts/examples/echo.js
  87. 37 0
      scripts/examples/github.sh
  88. 38 0
      scripts/examples/gitlab.sh
  89. 12 0
      scripts/long.sh
  90. 16 0
      tooling/bench/simple.js
  91. 38 0
      tooling/html/console.html
  92. 40 0
      tooling/httpsig/Makefile
  93. 33 0
      tooling/httpsig/README.md
  94. 15 0
      tooling/httpsig/go.mod
  95. 13 0
      tooling/httpsig/go.sum
  96. 118 0
      tooling/httpsig/main.go
  97. 110 0
      webhookd.svg

+ 1 - 0
.dockerignore

@@ -0,0 +1 @@
+release/

+ 101 - 0
.github/workflows/build.yml

@@ -0,0 +1,101 @@
+name: Build
+
+on:
+  push:
+    branches: [ master ]
+    tags: [ 'v*' ]
+  pull_request:
+    branches: [ master ]
+
+jobs:
+  # Build and test project
+  build:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v4
+      with:
+        submodules: recursive
+    - uses: actions/setup-go@v4
+      with:
+        go-version: stable
+    - run: make build test
+
+  # Create project release if tagged
+  release:
+    runs-on: ubuntu-latest
+    if: startsWith(github.ref, 'refs/tags/')
+    needs: build
+    steps:
+    - uses: actions/checkout@v4
+      with:
+        submodules: recursive
+    - uses: actions/setup-go@v4
+      with:
+        go-version: stable
+    - uses: actions/setup-node@v3
+      with:
+        node-version: current
+    - run: npm install -g standard-changelog
+    - run: make distribution
+    - name: get CHANGELOG
+      id: changelog
+      uses: requarks/changelog-action@v1
+      with:
+        token: ${{ github.token }}
+        tag: ${{ github.ref_name }}
+    - uses: softprops/action-gh-release@v1
+      with:
+        body: ${{ steps.changelog.outputs.changes }}
+        files: |
+          release/webhookd-linux-amd64.tgz
+          release/webhookd-linux-arm64.tgz
+          release/webhookd-linux-arm.tgz
+          release/webhookd-darwin-amd64.tgz
+      env:
+        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+  # Publish Docker image
+  publish:
+    runs-on: ubuntu-latest
+    needs: build
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - target: slim
+            suffix: ''
+          - target: distrib
+            suffix: -distrib
+    steps:
+    - uses: actions/checkout@v4
+      with:
+        submodules: recursive
+    - name: extract metadata (tags, labels) for Docker
+      id: meta
+      uses: docker/metadata-action@v3
+      with:
+        images: ncarlier/webhookd
+        flavor: suffix=${{ matrix.suffix }}
+        tags: |
+          type=edge
+          type=semver,pattern={{major}}
+          type=semver,pattern={{version}}
+    - uses: docker/setup-qemu-action@v1
+      with:
+        image: tonistiigi/binfmt:latest
+        platforms: arm64,arm
+    - uses: docker/setup-buildx-action@v1 
+    - uses: docker/login-action@v1
+      if: github.event_name != 'pull_request'
+      with:
+        username: ${{ secrets.DOCKER_HUB_USERNAME }}
+        password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN  }}      
+    - name: Build and push Docker image (${{ matrix.target }})
+      uses: docker/build-push-action@v2
+      with:
+        context: .
+        target: ${{ matrix.target }}
+        platforms: linux/amd64,linux/arm64,linux/arm/v7
+        push: ${{ github.event_name != 'pull_request' }}
+        tags: ${{ steps.meta.outputs.tags }}
+        labels: ${{ steps.meta.outputs.labels }}

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+release/
+.vscode/
+.htpasswd
+*.pem
+*.key
+CHANGELOG.md

+ 3 - 0
.gitmodules

@@ -0,0 +1,3 @@
+[submodule "makefiles"]
+	path = makefiles
+	url = https://github.com/ncarlier/makefiles.git

+ 81 - 0
CONTRIBUTING.md

@@ -0,0 +1,81 @@
+# Contributing Guidelines
+
+Some basic conventions for contributing to this project.
+
+## General
+
+Please make sure that there aren't existing pull requests attempting to address
+the issue mentioned. Likewise, please check for issues related to update, as
+someone else may be working on the issue in a branch or fork.
+
+* Non-trivial changes should be discussed in an issue first
+* Develop in a topic branch, not master
+* Squash your commits
+
+## Commit Message Format
+
+Each commit message should include a **type**, a **scope** and a **subject**:
+
+```
+ <type>(<scope>): <subject>
+```
+
+Lines should not exceed 100 characters. This allows the message to be easier to
+read on GitLab as well as in various git tools and produces a nice, neat commit
+log ie:
+
+```
+ #271 feat(standard): add style config and refactor to match
+ #270 fix(config): only override publicPath when served by webpack
+ #269 feat(eslint-config-defaults): replace eslint-config-airbnb
+ #268 feat(config): allow user to configure webpack stats output
+```
+
+### Type
+
+Must be one of the following:
+
+* **feat**: A new feature
+* **fix**: A bug fix
+* **docs**: Documentation only changes
+* **style**: Changes that do not affect the meaning of the code (white-space,
+  formatting, semi-colons, etc)
+* **refactor**: A code change that neither fixes a bug or adds a feature
+* **test**: Adding missing tests
+* **chore**: Changes to the build process or auxiliary tools and libraries such
+  as documentation generation
+
+### Scope
+
+The scope could be anything specifying place of the commit change. For example
+`security`, `api`, etc...
+
+### Subject
+
+The subject contains succinct description of the change:
+
+* use the imperative, present tense: "change" not "changed" nor "changes"
+* don't capitalize first letter
+* no dot (.) at the end
+
+
+## Release
+
+Generate the changelog (by using
+[conventional changelog](https://github.com/conventional-changelog]) CLI):
+
+```bash
+$ standard-changelog --first-release
+```
+
+Tag the version according to the [semantic versioning rules](https://semver.org/)
+and deploy the release:
+
+```bash
+$ git tag -a 1.0.0-beta.2
+$ # Use the changelog header as tag comment.
+$ git push --tags
+```
+
+---
+

+ 99 - 0
Dockerfile

@@ -0,0 +1,99 @@
+#########################################
+# Build stage
+#########################################
+FROM golang:1.21 AS builder
+
+# Repository location
+ARG REPOSITORY=github.com/ncarlier
+
+# Artifact name
+ARG ARTIFACT=webhookd
+
+# Copy sources into the container
+ADD . /go/src/$REPOSITORY/$ARTIFACT
+
+# Set working directory
+WORKDIR /go/src/$REPOSITORY/$ARTIFACT
+
+# Build the binary
+RUN make
+
+#########################################
+# Distribution stage
+#########################################
+FROM alpine:latest AS slim
+
+# Repository location
+ARG REPOSITORY=github.com/ncarlier
+
+# Artifact name
+ARG ARTIFACT=webhookd
+
+# User
+ARG USER=webhookd
+ARG UID=1000
+
+# Create non-root user
+RUN adduser \
+    --disabled-password \
+    --gecos "" \
+    --home "$(pwd)" \
+    --no-create-home \
+    --uid "$UID" \
+    "$USER"
+
+# Install deps
+RUN apk add --no-cache bash gcompat
+
+# Install binary
+COPY --from=builder /go/src/$REPOSITORY/$ARTIFACT/release/$ARTIFACT /usr/local/bin/$ARTIFACT
+
+VOLUME [ "/scripts" ]
+
+EXPOSE 8080
+
+USER $USER
+
+CMD [ "webhookd" ]
+
+#########################################
+# Distribution stage with some tooling
+#########################################
+FROM alpinelinux/docker-cli:latest AS distrib
+
+# Repository location
+ARG REPOSITORY=github.com/ncarlier
+
+# Artifact name
+ARG ARTIFACT=webhookd
+
+# User
+ARG USER=webhookd
+ARG UID=1000
+
+# Create non-root user
+RUN adduser \
+    --disabled-password \
+    --gecos "" \
+    --home "$(pwd)" \
+    --no-create-home \
+    --uid "$UID" \
+    "$USER"
+
+# Install deps
+RUN apk add --no-cache bash gcompat git openssl openssh-client curl jq docker-cli-compose aha
+
+# Install binary and entrypoint
+COPY --from=builder /go/src/$REPOSITORY/$ARTIFACT/release/$ARTIFACT /usr/local/bin/$ARTIFACT
+COPY docker-entrypoint.sh /
+
+# Define entrypoint
+ENTRYPOINT ["/docker-entrypoint.sh"]
+
+VOLUME [ "/scripts" ]
+
+EXPOSE 8080
+
+USER $USER
+
+CMD [ "webhookd" ]

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2020 Nicolas Carlier
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 110 - 0
Makefile

@@ -0,0 +1,110 @@
+.SILENT :
+
+# App name
+APPNAME=webhookd
+
+# Go configuration
+GOOS?=$(shell go env GOHOSTOS)
+GOARCH?=$(shell go env GOHOSTARCH)
+
+# Add exe extension if windows target
+is_windows:=$(filter windows,$(GOOS))
+EXT:=$(if $(is_windows),".exe","")
+
+# Archive name
+ARCHIVE=$(APPNAME)-$(GOOS)-$(GOARCH).tgz
+
+# Executable name
+EXECUTABLE=$(APPNAME)$(EXT)
+
+# Extract version infos
+PKG_VERSION:=github.com/ncarlier/$(APPNAME)/pkg/version
+VERSION:=`git describe --always --tags --dirty`
+GIT_COMMIT:=`git rev-list -1 HEAD --abbrev-commit`
+BUILT:=`date`
+define LDFLAGS
+-X '$(PKG_VERSION).Version=$(VERSION)' \
+-X '$(PKG_VERSION).GitCommit=$(GIT_COMMIT)' \
+-X '$(PKG_VERSION).Built=$(BUILT)' \
+-s -w -buildid=
+endef
+
+all: build
+
+# Include common Make tasks
+root_dir:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
+makefiles:=$(root_dir)/makefiles
+include $(makefiles)/help.Makefile
+
+## Clean built files
+clean:
+	-rm -rf release
+.PHONY: clean
+
+## Build executable
+build:
+	-mkdir -p release
+	echo ">>> Building: $(EXECUTABLE) $(VERSION) for $(GOOS)-$(GOARCH) ..."
+	GOOS=$(GOOS) GOARCH=$(GOARCH) go build -tags osusergo,netgo -ldflags "$(LDFLAGS)" -o release/$(EXECUTABLE)
+.PHONY: build
+
+release/$(EXECUTABLE): build
+
+# Check code style
+check-style:
+	echo ">>> Checking code style..."
+	go vet ./...
+	go run honnef.co/go/tools/cmd/staticcheck@latest ./...
+.PHONY: check-style
+
+# Check code criticity
+check-criticity:
+	echo ">>> Checking code criticity..."
+	go run github.com/go-critic/go-critic/cmd/gocritic@latest check -enableAll ./...
+.PHONY: check-criticity
+
+# Check code security
+check-security:
+	echo ">>> Checking code security..."
+	go run github.com/securego/gosec/v2/cmd/gosec@latest -quiet ./...
+.PHONY: check-security
+
+## Code quality checks
+checks: check-style check-criticity
+.PHONY: checks
+
+## Run tests
+test: 
+	go test ./...
+.PHONY: test
+
+## Install executable
+install: release/$(EXECUTABLE)
+	echo "Installing $(EXECUTABLE) to ${HOME}/.local/bin/$(EXECUTABLE) ..."
+	cp release/$(EXECUTABLE) ${HOME}/.local/bin/$(EXECUTABLE)
+.PHONY: install
+
+## Create Docker image
+image:
+	echo "Building Docker image ..."
+	docker build --rm --target slim -t ncarlier/$(APPNAME) .
+.PHONY: image
+
+# Generate changelog
+CHANGELOG.md:
+	standard-changelog --first-release
+
+## Create archive
+archive: release/$(EXECUTABLE) CHANGELOG.md
+	echo "Creating release/$(ARCHIVE) archive..."
+	tar czf release/$(ARCHIVE) README.md LICENSE CHANGELOG.md -C release/ $(EXECUTABLE)
+	rm release/$(EXECUTABLE)
+.PHONY: archive
+
+## Create distribution binaries
+distribution:
+	GOARCH=amd64 make build archive
+	GOARCH=arm64 make build archive
+	GOARCH=arm make build archive
+	GOOS=darwin make build archive
+.PHONY: distribution

+ 435 - 0
README.md

@@ -0,0 +1,435 @@
+![Notice, this repository was mirrored to here from Github](https://m1s5.c20.e2-5.dev/files/images/mirror-notice.svg)
+
+# webhookd
+
+[![Build Status](https://github.com/ncarlier/webhookd/actions/workflows/build.yml/badge.svg)](https://github.com/ncarlier/webhookd/actions/workflows/build.yml)
+[![Go Report Card](https://goreportcard.com/badge/github.com/ncarlier/webhookd)](https://goreportcard.com/report/github.com/ncarlier/webhookd)
+[![Docker pulls](https://img.shields.io/docker/pulls/ncarlier/webhookd.svg)](https://hub.docker.com/r/ncarlier/webhookd/)
+[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.me/nunux)
+
+A very simple webhook server to launch shell scripts.
+
+![Logo](webhookd.svg)
+
+## At a glance
+
+![Demo](demo.gif)
+
+## Installation
+
+Run the following command:
+
+```bash
+$ go install github.com/ncarlier/webhookd@latest
+```
+
+**Or** download the binary regarding your architecture:
+
+```bash
+$ sudo curl -s https://raw.githubusercontent.com/ncarlier/webhookd/master/install.sh | bash
+or
+$ curl -sf https://gobinaries.com/ncarlier/webhookd | sh
+```
+
+**Or** use Docker:
+
+```bash
+$ docker run -d --name=webhookd \
+  -v ${PWD}/scripts:/scripts \
+  -p 8080:8080 \
+  ncarlier/webhookd
+```
+
+> Note: The official Docker image is lightweight and allows to run simple scripts but for more advanced needs you can use the `ncarlier/webhookd:edge-distrib` image.
+> For example, with this `distrib` image, you can interact with your Docker daemon using Docker CLI or Docker Compose.
+
+**Or** use APT:
+
+Finally, it is possible to install Webhookd using the Debian packaging system through this [custom repository](https://packages.azlux.fr/).
+
+> Note: Custom configuration variables can be set into `/etc/webhookd.env` file.
+> Sytemd service is already set and enable, you just have to start it with `systemctl start webhookd`.
+
+## Configuration
+
+Webhookd can be configured by using command line parameters or by setting environment variables.
+
+Type `webhookd -h` to display all parameters and related environment variables.
+
+All configuration variables are described in [etc/default/webhookd.env](./etc/default/webhookd.env) file.
+
+## Usage
+
+### Directory structure
+
+Webhooks are simple scripts within a directory structure.
+
+By default inside the `./scripts` directory.
+You can change the default directory using the `WHD_HOOK_SCRIPTS` environment variable or `-hook-scripts` parameter.
+
+*Example:*
+
+```
+/scripts
+|--> /github
+  |--> /build.sh
+  |--> /deploy.sh
+|--> /push.js
+|--> /echo.sh
+|--> ...
+```
+
+> Note: Webhookd is able to run any type of file in this directory as long as the file is executable.
+For example, you can execute a Node.js file if you give execution rights to the file and add the appropriate `#!` header (in this case: `#!/usr/bin/env node`).
+
+You can find sample scripts in the [example folder](./scripts/examples).
+In particular, examples of integration with Gitlab and Github.
+
+### Webhook call
+
+The directory structure define the webhook URL.
+
+You can omit the script extension. If you do, webhookd will search by default for a `.sh` file.
+You can change the default extension using the `WHD_HOOK_DEFAULT_EXT` environment variable or `-hook-default-ext` parameter.
+If the script exists, the output will be send to the HTTP response.
+
+Depending on the HTTP request, the HTTP response will be a HTTP `200` code with the script's output in real time (streaming), or the HTTP response will wait until the end of the script's execution and return the output (tuncated) of the script as well as an HTTP code relative to the script's output code.
+
+The streaming protocol depends on the HTTP request:
+
+- [Server-sent events][sse] is used when `Accept` HTTP header is equal to `text/event-stream`.
+- [Chunked Transfer Coding][chunked] is used when `X-Hook-Mode` HTTP header is equal to `chunked`.
+It's the default mode.
+You can change the default mode using the `WHD_HOOK_DEFAULT_MODE` environment variable or `-hook-default-mode` parameter.
+
+[sse]: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
+[chunked]: https://datatracker.ietf.org/doc/html/rfc2616#section-3.6.1
+
+If no streaming protocol is needed, you must set `X-Hook-Mode` HTTP header to `buffered`.
+The HTTP reponse will block until the script is over:
+
+- Sends script output limited to the last 100 lines. You can modify this limit via the HTTP header `X-Hook-MaxBufferedLines`.
+- Convert the script exit code to HTTP code as follow:
+  - 0: `200 OK`
+  - Between 1 and 99: `500 Internal Server Error`
+  - Between 100 and 255: Add 300 to get HTTP code between 400 and 555
+
+> Remember: a process exit code is between 0 and 255. 0 means that the execution is successful.
+
+*Example:*
+
+The script: `./scripts/foo/bar.sh`
+
+```bash
+#!/bin/bash
+
+echo "foo foo foo"
+echo "bar bar bar"
+
+exit 118
+```
+
+Streamed output using  `Server-sent events`:
+
+```bash
+$ curl -v --header "Accept: text/event-stream" -XGET http://localhost:8080/foo/bar
+< HTTP/1.1 200 OK
+< Content-Type: text/event-stream
+< Transfer-Encoding: chunked
+< X-Hook-Id: 8
+
+data: foo foo foo
+
+data: bar bar bar
+
+error: exit status 118
+```
+
+Streamed output using `Chunked Transfer Coding`:
+
+```bash
+$ curl -v -XPOST --header "X-Hook-Mode: chunked" http://localhost:8080/foo/bar
+< HTTP/1.1 200 OK
+< Content-Type: text/plain; charset=utf-8
+< Transfer-Encoding: chunked
+< X-Hook-Id: 7
+
+foo foo foo
+bar bar bar
+error: exit status 118
+
+```
+
+Blocking HTTP request:
+
+```bash
+$ curl -v -XPOST --header "X-Hook-Mode: buffered" http://localhost:8080/foo/bar
+< HTTP/1.1 418 I m a teapot
+< Content-Type: text/plain; charset=utf-8
+< X-Hook-Id: 9
+
+foo foo foo
+bar bar bar
+error: exit status 118
+```
+
+> Note that in this last example the HTTP response is equal to `exit code + 300` : `418 I'm a teapot`.
+
+### Webhook parameters
+
+You have several ways to provide parameters to your webhook script:
+
+- URL request parameters are converted to script variables
+- HTTP headers are converted to script variables
+- Request body (depending the Media Type):
+  - `application/x-www-form-urlencoded`: keys and values are converted to script variables
+  - `text/*` or `application/json`: payload is transmit to the script as first parameter.
+
+> Note: Variable name follows "snakecase" naming convention.
+Therefore the name can be altered.
+*ex: `CONTENT-TYPE` will become `content_type`.*
+
+Webhookd adds some additional parameters to the script:
+
+- `hook_id`: hook ID (auto-increment)
+- `hook_name`: hook name
+- `hook_method`: HTTP request method
+- `x_forwarded_for`: client IP
+- `x_webauth_user`: username if authentication is enabled
+
+*Example:*
+
+The script:
+
+```bash
+#!/bin/bash
+
+echo "Hook information: name=$hook_name, id=$hook_id, method=$hook_method"
+echo "Query parameter: foo=$foo"
+echo "Header parameter: user-agent=$user_agent"
+echo "Script parameters: $1"
+```
+
+The result:
+
+```bash
+$ curl --data @test.json -H 'Content-Type: application/json' http://localhost:8080/echo?foo=bar
+Hook information: name=echo, id=1, method=POST
+Query parameter: foo=bar
+Header parameter: user-agent=curl/7.52.1
+Script parameter: {"message": "this is a test"}
+```
+
+### Webhook timeout configuration
+
+By default a webhook has a timeout of 10 seconds.
+This timeout is globally configurable by setting the environment variable:
+`WHD_HOOK_TIMEOUT` (in seconds).
+
+You can override this global behavior per request by setting the HTTP header:
+`X-Hook-Timeout` (in seconds).
+
+*Example:*
+
+```bash
+$ curl -H "X-Hook-Timeout: 5" http://localhost:8080/echo?foo=bar
+```
+
+### Webhook logs
+
+As mentioned above, web hook logs are stream in real time during the call.
+However, you can retrieve the logs of a previous call by using the hook ID: `http://localhost:8080/<NAME>/<ID>`
+
+The hook ID is returned as an HTTP header with the Webhook response: `X-Hook-ID`
+
+*Example:*
+
+```bash
+$ # Call webhook
+$ curl -v http://localhost:8080/echo?foo=bar
+...
+< HTTP/1.1 200 OK
+< Content-Type: text/plain
+< X-Hook-Id: 2
+...
+$ # Retrieve logs afterwards
+$ curl http://localhost:8080/echo/2
+```
+
+If needed, you can also redirect hook logs to the server output (configured by the `WHD_LOG_MODULES=hook` environment variable).
+
+### Post hook notifications
+
+The output of the script is collected and stored into a log file
+(configured by the `WHD_HOOK_LOG_DIR` environment variable).
+
+Once the script is executed, you can send the result and this log file to a notification channel.
+Currently, only two channels are supported: `Email` and `HTTP`.
+
+Notifications configuration can be done as follow:
+
+```bash
+$ export WHD_NOTIFICATION_URI=http://requestb.in/v9b229v9
+$ # or
+$ webhookd --notification-uri=http://requestb.in/v9b229v9
+```
+
+> Note: Only the output of the script prefixed by "notify:" is sent to the notification channel.
+If the output does not contain a prefixed line, no notification will be sent.
+
+**Example:**
+
+```bash
+#!/bin/bash
+
+echo "notify: Hello World" # Will be notified
+echo "Goodbye"             # Will not be notified
+```
+
+You can override the notification prefix by adding `prefix` as a query parameter to the configuration URL.
+
+**Example:** http://requestb.in/v9b229v9?prefix="foo:"
+
+#### HTTP notification
+
+Configuration URI: `http://example.org`
+
+Options (using query parameters):
+
+- `prefix`: Prefix to filter output log
+
+The following JSON payload is POST to the target URL:
+
+```json
+{
+  "id": "42",
+  "name": "echo",
+  "text": "foo
+bar...
+",
+  "error": "Error cause... if present",
+}
+```
+
+> Note: that because the payload have a `text` attribute, you can use a [Mattermost][mattermost], [Slack][slack] or [Discord][discord] webhook endpoint.
+
+[mattermost]: https://docs.mattermost.com/developer/webhooks-incoming.html
+[discord]: https://discord.com/developers/docs/resources/webhook#execute-slackcompatible-webhook
+[slack]: https://api.slack.com/messaging/webhooks
+
+#### Email notification
+
+Configuration URI: `mailto:foo@bar.com`
+
+Options (using query parameters):
+
+- `prefix`: Prefix to filter output log
+- `smtp`: SMTP host to use (by default: `localhost:25`)
+- `username`: SMTP username (not set by default)
+- `password`: SMTP password (not set by default)
+- `conn`: SMTP connection type (`tls`, `tls-insecure` or by default: `plain`)
+- `from`: Sender email (by default: `noreply@nunux.org`)
+- `subject`: Email subject (by default: `[whd-notification] {name}#{id} {status}`)
+
+### Authentication
+
+You can restrict access to webhooks using HTTP basic authentication.
+
+To activate basic authentication, you have to create a `htpasswd` file:
+
+```bash
+$ # create passwd file the user 'api'
+$ htpasswd -B -c .htpasswd api
+```
+This command will ask for a password and store it in the htpawsswd file.
+
+By default, the daemon will try to load the `.htpasswd` file.
+But you can override this behavior by specifying the location of the file:
+
+```bash
+$ export WHD_PASSWD_FILE=/etc/webhookd/users.htpasswd
+$ # or
+$ webhookd --passwd-file /etc/webhookd/users.htpasswd
+```
+
+Once configured, you must call webhooks using basic authentication:
+
+```bash
+$ curl -u api:test -XPOST "http://localhost:8080/echo?msg=hello"
+```
+
+### Signature
+
+You can ensure message integrity (and authenticity) by signing HTTP requests.
+
+Webhookd supports 2 signature methods:
+
+- [HTTP Signatures](https://www.ietf.org/archive/id/draft-cavage-http-signatures-12.txt)
+- [Ed25519 Signature](https://ed25519.cr.yp.to/) (used by [Discord](https://discord.com/developers/docs/interactions/receiving-and-responding#security-and-authorization))
+
+To activate request signature verification, you have to configure the truststore:
+
+```bash
+$ export WHD_TRUSTSTORE_FILE=/etc/webhookd/pubkey.pem
+$ # or
+$ webhookd --truststore-file /etc/webhookd/pubkey.pem
+```
+
+Public key is stored in PEM format.
+
+Once configured, you must call webhooks using a valid signature:
+
+```bash
+# Using HTTP Signature:
+$ curl -X POST \
+  -H 'Date: <req-date>' \
+  -H 'Signature: keyId=<key-id>,algorithm="rsa-sha256",headers="(request-target) date",signature=<signature-string>' \
+  -H 'Accept: application/json' \
+  "http://localhost:8080/echo?msg=hello"
+# or using Ed25519 Signature:
+$ curl -X POST \
+  -H 'X-Signature-Timestamp: <timestamp>' \
+  -H 'X-Signature-Ed25519: <signature-string>' \
+  -H 'Accept: application/json' \
+  "http://localhost:8080/echo?msg=hello"
+```
+
+You can find a small HTTP client in the ["tooling" directory](./tooling/httpsig/README.md) that is capable of forging `HTTP signatures`.
+
+### TLS
+
+You can activate TLS to secure communications:
+
+```bash
+$ export WHD_TLS_ENABLED=true
+$ # or
+$ webhookd --tls-enabled
+```
+
+By default webhookd is expecting a certificate and key file (`./server.pem` and `./server.key`).
+You can provide your own certificate and key with `-tls-cert-file` and `-tls-key-file`.
+
+Webhookd also support [ACME](https://ietf-wg-acme.github.io/acme/) protocol.
+You can activate ACME by setting a fully qualified domain name:
+
+```bash
+$ export WHD_TLS_ENABLED=true
+$ export WHD_TLS_DOMAIN=hook.example.com
+$ # or
+$ webhookd --tls-enabled --tls-domain=hook.example.com
+```
+
+**Note:**
+On *nix, if you want to listen on ports 80 and 443, don't forget to use `setcap` to privilege the binary:
+
+```bash
+sudo setcap CAP_NET_BIND_SERVICE+ep webhookd
+```
+
+## License
+
+The MIT License (MIT)
+
+See [LICENSE](./LICENSE) to see the full text.
+
+---

BIN
demo.gif


+ 14 - 0
docker-compose.yml

@@ -0,0 +1,14 @@
+version: "3.6"
+
+services:
+  webhookd:
+    hostname: webhookd
+    image: ncarlier/webhookd:latest
+    container_name: webhookd
+    restart: always
+    ports:
+    - "8080:8080"
+    environment:
+    - WHD_HOOK_SCRIPTS=/scripts
+    volumes:
+    - ./scripts:/scripts

+ 21 - 0
docker-entrypoint.sh

@@ -0,0 +1,21 @@
+#!/bin/sh
+
+# Error function
+die() { echo "error: $@" 1>&2 ; exit 1; }
+
+if [ ! -z "$WHD_SCRIPTS_GIT_URL" ]
+then
+  [ ! -f "$WHD_SCRIPTS_GIT_KEY" ] && die "Git clone key not found."
+
+  export WHD_HOOK_SCRIPTS=${WHD_HOOK_SCRIPTS:-/opt/scripts-git}
+  export GIT_SSH_COMMAND="ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no"
+
+  mkdir -p $WHD_HOOK_SCRIPTS
+
+  echo "Cloning $WHD_SCRIPTS_GIT_URL into $WHD_HOOK_SCRIPTS ..."
+  ssh-agent sh -c 'ssh-add ${WHD_SCRIPTS_GIT_KEY}; git clone --depth 1 --single-branch ${WHD_SCRIPTS_GIT_URL} ${WHD_HOOK_SCRIPTS}'
+  [ $? != 0 ] && die "Unable to clone repository"
+fi
+
+exec "$@"
+

+ 68 - 0
etc/default/webhookd.env

@@ -0,0 +1,68 @@
+###
+# Webhookd configuration
+###
+
+# HTTP listen address, default is ":8080"
+# Example: `localhost:8080` or `:8080` for all interfaces
+#WHD_LISTEN_ADDR=":8080"
+
+# Log level (debug, info, warn or error), default is "info"
+#WHD_LOG_LEVEL=info
+# Log format (text or json), default is "text"
+#WHD_LOG_FORMAT=text
+# Logging modules to activate (http, hook)
+# - `http`: HTTP access logs
+# - `hook`: Hook execution logs
+# Example: `http` or `http,hook`
+#WHD_LOG_MODULES=
+
+
+# Default extension for hook scripts, default is "sh"
+#WHD_HOOK_DEFAULT_EXT=sh
+# Default hook HTTP response mode (chunked or buffered), default is "chunked"
+#WHD_HOOK_DEFAULT_MODE=chunked
+# Maximum hook execution time in second, default is 10
+#WHD_HOOK_TIMEOUT=10
+# Scripts location, default is "scripts"
+#WHD_HOOK_SCRIPTS="scripts"
+# Hook execution logs location, default is OS temporary directory
+#WHD_HOOK_LOG_DIR="/tmp"
+# Number of workers to start, default is 2
+#WHD_HOOK_WORKERS=2
+
+# Static file directory to serve on /static path, disabled by default
+# Example: `./var/www`
+#WHD_STATIC_DIR=
+# Path to serve static file directory, default is "/static"
+#WHD_STATIC_PATH=/static
+
+# Notification URI, disabled by default
+# Example: `http://requestb.in/v9b229v9` or `mailto:foo@bar.com?smtp=smtp-relay-localnet:25`
+#WHD_NOTIFICATION_URI=
+
+# Password file for HTTP basic authentication, default is ".htpasswd"
+#WHD_PASSWD_FILE=".htpasswd"
+
+# Truststore URI, disabled by default
+# Enable HTTP signature verification if set.
+# Example: `/etc/webhookd/pubkey.pem`
+#WHD_TRUSTSTORE_FILE=
+
+# Activate TLS, default is false
+#WHD_TLS_ENABLED=false
+# TLS key file, default is "./server.key"
+#WHD_TLS_KEY_FILE="./server.key"
+# TLS certificate file, default is "./server.crt"
+#WHD_TLS_CERT_FILE="./server.pem"
+# TLS domain name used by ACME, key and cert files are ignored if set
+# Example: `hook.example.org`
+#WHD_TLS_DOMAIN=
+
+# GIT repository that contains scripts
+# Note: this is only used by the Docker image or by using the Docker entrypoint script
+# Example: `git@github.com:ncarlier/webhookd.git`
+#WHD_SCRIPTS_GIT_URL=
+# GIT SSH private key used to clone the repository
+# Note: this is only used by the Docker image or by using the Docker entrypoint script
+# Example: `/etc/webhookd/github_deploy_key.pem`
+#WHD_SCRIPTS_GIT_KEY=

+ 14 - 0
go.mod

@@ -0,0 +1,14 @@
+module github.com/ncarlier/webhookd
+
+require (
+	github.com/go-fed/httpsig v1.1.0
+	golang.org/x/crypto v0.13.0
+)
+
+require (
+	golang.org/x/net v0.10.0 // indirect
+	golang.org/x/sys v0.12.0 // indirect
+	golang.org/x/text v0.13.0 // indirect
+)
+
+go 1.21

+ 18 - 0
go.sum

@@ -0,0 +1,18 @@
+github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
+github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=

+ 45 - 0
install.sh

@@ -0,0 +1,45 @@
+#!/bin/bash
+
+die() { echo "error: $@" 1>&2 ; exit 1; }
+
+# Getting operating system
+os=`uname -s`
+os=${os,,}
+
+# Getting architecture
+arch=`uname -m`
+case "$arch" in
+"armv7l")
+    arch="arm"
+    ;;
+"x86_64")
+    arch="amd64"
+    ;;
+esac
+
+release_url="https://api.github.com/repos/ncarlier/webhookd/releases/latest"
+artefact_url=`curl -s $release_url | grep browser_download_url | head -n 1 | cut -d '"' -f 4`
+[ -z "$artefact_url" ] && die "Unable to extract artefact URL"
+base_download_url=`dirname $artefact_url`
+
+download_url=$base_download_url/webhookd-$os-${arch}.tgz
+download_file=/tmp/webhookd-$os-${arch}.tgz
+bin_target=${1:-$HOME/.local/bin}
+
+echo "Downloading $download_url to $download_file ..."
+curl -o $download_file --fail -L $download_url
+[ $? != 0 ] && die "Unable to download binary for your architecture."
+
+echo "Extracting $download_file to $bin_target ..."
+[ -d $bin_target ] || mkdir -p $bin_target
+tar xvzf ${download_file} -C $bin_target
+[ $? != 0 ] && die "Unable to extract archive."
+
+echo "Cleaning..."
+rm $download_file \
+   $bin_target/LICENSE \
+   $bin_target/README.md \
+   $bin_target/CHANGELOG.md
+[ $? != 0 ] && die "Unable to clean installation files."
+
+echo "Installation done. Type '$bin_target/webhookd' to start the server."

+ 93 - 0
main.go

@@ -0,0 +1,93 @@
+package main
+
+import (
+	"context"
+	"flag"
+	"log"
+	"log/slog"
+	"net/http"
+	"os"
+	"os/signal"
+	"slices"
+	"syscall"
+	"time"
+
+	"github.com/ncarlier/webhookd/pkg/api"
+	"github.com/ncarlier/webhookd/pkg/config"
+	configflag "github.com/ncarlier/webhookd/pkg/config/flag"
+	"github.com/ncarlier/webhookd/pkg/logger"
+	"github.com/ncarlier/webhookd/pkg/notification"
+	_ "github.com/ncarlier/webhookd/pkg/notification/all"
+	"github.com/ncarlier/webhookd/pkg/server"
+	"github.com/ncarlier/webhookd/pkg/version"
+	"github.com/ncarlier/webhookd/pkg/worker"
+)
+
+const envPrefix = "WHD"
+
+func main() {
+	conf := &config.Config{}
+	configflag.Bind(conf, envPrefix)
+
+	flag.Parse()
+
+	if *version.ShowVersion {
+		version.Print()
+		os.Exit(0)
+	}
+
+	if conf.Hook.LogDir == "" {
+		conf.Hook.LogDir = os.TempDir()
+	}
+
+	if err := conf.Validate(); err != nil {
+		log.Fatal("invalid configuration:", err)
+	}
+
+	logger.Configure(conf.Log.Format, conf.Log.Level)
+	logger.HookOutputEnabled = slices.Contains(conf.Log.Modules, "hook")
+	logger.RequestOutputEnabled = slices.Contains(conf.Log.Modules, "http")
+
+	conf.ManageDeprecatedFlags(envPrefix)
+
+	slog.Debug("starting webhookd server...")
+
+	srv := server.NewServer(conf)
+
+	// Configure notification
+	if err := notification.Init(conf.Notification.URI); err != nil {
+		slog.Error("unable to create notification channel", "err", err)
+	}
+
+	// Start the dispatcher.
+	slog.Debug("starting the dispatcher...", "workers", conf.Hook.Workers)
+	worker.StartDispatcher(conf.Hook.Workers)
+
+	done := make(chan bool)
+	quit := make(chan os.Signal, 1)
+	signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
+
+	go func() {
+		<-quit
+		slog.Debug("server is shutting down...")
+		api.Shutdown()
+
+		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+		defer cancel()
+
+		if err := srv.Shutdown(ctx); err != nil {
+			slog.Error("could not gracefully shutdown the server", "err", err)
+		}
+		close(done)
+	}()
+
+	api.Start()
+	slog.Info("server started", "addr", conf.ListenAddr)
+	if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+		slog.Error("unable to start the server", "addr", conf.ListenAddr, "err", err)
+		os.Exit(1)
+	}
+
+	<-done
+	slog.Debug("server stopped")
+}

+ 32 - 0
pkg/api/healthz.go

@@ -0,0 +1,32 @@
+package api
+
+import (
+	"net/http"
+	"sync/atomic"
+
+	"github.com/ncarlier/webhookd/pkg/config"
+)
+
+var (
+	healthy int32
+)
+
+// Shutdown set API as stopped
+func Shutdown() {
+	atomic.StoreInt32(&healthy, 0)
+}
+
+// Start set API as started
+func Start() {
+	atomic.StoreInt32(&healthy, 1)
+}
+
+func healthz(conf *config.Config) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if atomic.LoadInt32(&healthy) == 1 {
+			w.WriteHeader(http.StatusNoContent)
+			return
+		}
+		w.WriteHeader(http.StatusServiceUnavailable)
+	})
+}

+ 33 - 0
pkg/api/helper.go

@@ -0,0 +1,33 @@
+package api
+
+import (
+	"bytes"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/ncarlier/webhookd/pkg/helper"
+)
+
+// HTTPParamsToShellVars convert URL values to shell vars.
+func HTTPParamsToShellVars[T url.Values | http.Header](params T) []string {
+	var result []string
+	for k, v := range params {
+		var buf bytes.Buffer
+		value, err := url.QueryUnescape(strings.Join(v, ","))
+		if err != nil {
+			continue
+		}
+		buf.WriteString(helper.ToSnake(k))
+		buf.WriteString("=")
+		buf.WriteString(value)
+		result = append(result, buf.String())
+	}
+	return result
+}
+
+func nextRequestID() string {
+	return fmt.Sprintf("%d", time.Now().UnixNano())
+}

+ 276 - 0
pkg/api/index.go

@@ -0,0 +1,276 @@
+package api
+
+import (
+	"bytes"
+	"container/ring"
+	"fmt"
+	"io"
+	"log/slog"
+	"mime"
+	"net/http"
+	"path"
+	"path/filepath"
+	"strconv"
+	"strings"
+
+	"github.com/ncarlier/webhookd/pkg/config"
+	"github.com/ncarlier/webhookd/pkg/helper"
+	"github.com/ncarlier/webhookd/pkg/hook"
+	"github.com/ncarlier/webhookd/pkg/worker"
+)
+
+var (
+	defaultTimeout int
+	defaultExt     string
+	defaultMode    string
+	scriptDir      string
+	outputDir      string
+)
+
+const (
+	DefaultBufferLength = 100
+	MaxBufferLength     = 10000
+	SSEContentType      = "text/event-stream"
+)
+
+var supportedContentTypes = []string{"text/plain", SSEContentType, "application/json", "text/*"}
+
+func atoiFallback(str string, fallback int) int {
+	if value, err := strconv.Atoi(str); err == nil && value > 0 {
+		return value
+	}
+	return fallback
+}
+
+// index is the main handler of the API.
+func index(conf *config.Config) http.Handler {
+	defaultTimeout = conf.Hook.Timeout
+	defaultExt = conf.Hook.DefaultExt
+	scriptDir = conf.Hook.ScriptsDir
+	outputDir = conf.Hook.LogDir
+	defaultMode = conf.Hook.DefaultMode
+	return http.HandlerFunc(webhookHandler)
+}
+
+func webhookHandler(w http.ResponseWriter, r *http.Request) {
+	if r.Method == "GET" {
+		if _, err := strconv.Atoi(filepath.Base(r.URL.Path)); err == nil {
+			getWebhookLog(w, r)
+			return
+		}
+	}
+	triggerWebhook(w, r)
+}
+
+func triggerWebhook(w http.ResponseWriter, r *http.Request) {
+	// Manage content negotiation
+	negociatedContentType := helper.NegotiateContentType(r, supportedContentTypes, "text/plain")
+
+	// Extract streaming method
+	mode := r.Header.Get("X-Hook-Mode")
+	if mode != "buffered" && mode != "chunked" {
+		mode = defaultMode
+	}
+	if negociatedContentType == SSEContentType {
+		mode = "sse"
+	}
+
+	// Check that streaming is supported
+	if _, ok := w.(http.Flusher); !ok && mode != "buffered" {
+		http.Error(w, "streaming not supported", http.StatusInternalServerError)
+		return
+	}
+
+	// Get hook location
+	hookName := strings.TrimPrefix(r.URL.Path, "/")
+	if hookName == "" {
+		infoHandler(w, r)
+		return
+	}
+	script, err := hook.ResolveScript(scriptDir, hookName, defaultExt)
+	if err != nil {
+		msg := "hook not found"
+		slog.Error(msg, "err", err.Error())
+		http.Error(w, msg, http.StatusNotFound)
+		return
+	}
+
+	if err = r.ParseForm(); err != nil {
+		msg := "unable to parse form-data"
+		slog.Error(msg, "err", err)
+		http.Error(w, msg, http.StatusBadRequest)
+		return
+	}
+
+	// parse body
+	var body []byte
+	ct := r.Header.Get("Content-Type")
+	if ct != "" {
+		mediatype, _, _ := mime.ParseMediaType(ct)
+		switch {
+		case mediatype == "application/json", strings.HasPrefix(mediatype, "text/"):
+			body, err = io.ReadAll(r.Body)
+			if err != nil {
+				msg := "unable to read request body"
+				slog.Error(msg, "err", err)
+				http.Error(w, msg, http.StatusBadRequest)
+				return
+			}
+		case mediatype == "multipart/form-data":
+			if err := r.ParseMultipartForm(8 << 20); err != nil {
+				msg := "unable to parse multipart/form-data"
+				slog.Error(msg, "err", err)
+				http.Error(w, msg, http.StatusBadRequest)
+				return
+			}
+		default:
+			slog.Debug("unsuported media type", "media_type", mediatype)
+		}
+	}
+
+	params := HTTPParamsToShellVars(r.Form)
+	params = append(params, HTTPParamsToShellVars(r.Header)...)
+
+	// Create hook job
+	timeout := atoiFallback(r.Header.Get("X-Hook-Timeout"), defaultTimeout)
+	job, err := hook.NewHookJob(&hook.Request{
+		Name:      hookName,
+		Script:    script,
+		Method:    r.Method,
+		Payload:   string(body),
+		Args:      params,
+		Timeout:   timeout,
+		OutputDir: outputDir,
+	})
+	if err != nil {
+		msg := "unable to create hook execution job"
+		slog.Error(msg, "err", err)
+		http.Error(w, msg, http.StatusInternalServerError)
+		return
+	}
+
+	// Put work in queue
+	worker.WorkQueue <- job
+
+	// Write hook ouput to the response regarding the asked method
+	if mode != "buffered" {
+		// Write hook response as Server Sent Event stream
+		writeStreamedResponse(w, negociatedContentType, job, mode)
+	} else {
+		maxBufferLength := atoiFallback(r.Header.Get("X-Hook-MaxBufferedLines"), DefaultBufferLength)
+		if maxBufferLength > MaxBufferLength {
+			maxBufferLength = MaxBufferLength
+		}
+		// Write hook response after hook execution
+		writeStandardResponse(w, negociatedContentType, job, maxBufferLength)
+	}
+}
+
+func writeStreamedResponse(w http.ResponseWriter, negociatedContentType string, job *hook.Job, mode string) {
+	writeHeaders(w, negociatedContentType, job.ID())
+	for {
+		msg, open := <-job.MessageChan
+		if !open {
+			break
+		}
+
+		if mode == "sse" {
+			// Send SSE response
+			prefix := "data: "
+			if bytes.HasPrefix(msg, []byte("error:")) {
+				prefix = ""
+			}
+			fmt.Fprintf(w, "%s%s\n", prefix, msg)
+		} else {
+			// Send chunked response
+			w.Write(msg)
+		}
+
+		// Flush the data immediately instead of buffering it for later.
+		if flusher, ok := w.(http.Flusher); ok {
+			flusher.Flush()
+		}
+	}
+}
+
+func writeStandardResponse(w http.ResponseWriter, negociatedContentType string, job *hook.Job, maxBufferLength int) {
+	buffer := ring.New(maxBufferLength)
+	overflow := false
+	lines := 0
+
+	// Consume messages into a ring buffer
+	for {
+		msg, open := <-job.MessageChan
+		if !open {
+			break
+		}
+		buffer.Value = msg
+		buffer = buffer.Next()
+		lines++
+		if lines > maxBufferLength {
+			overflow = true
+		}
+	}
+
+	writeHeaders(w, negociatedContentType, job.ID())
+	w.WriteHeader(getJobStatusCode(job))
+	if overflow {
+		w.Write([]byte("[output truncated]\n"))
+	}
+	// Write buffer to HTTP response
+	buffer.Do(func(data interface{}) {
+		if data != nil {
+			w.Write(data.([]byte))
+		}
+	})
+}
+
+func getJobStatusCode(job *hook.Job) int {
+	switch {
+	case job.ExitCode() == 0:
+		return http.StatusOK
+	case job.ExitCode() >= 100:
+		return job.ExitCode() + 300
+	default:
+		return http.StatusInternalServerError
+	}
+}
+
+func writeHeaders(w http.ResponseWriter, contentType string, hookId uint64) {
+	w.Header().Set("Content-Type", contentType+"; charset=utf-8")
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Connection", "keep-alive")
+	w.Header().Set("X-Content-Type-Options", "nosniff")
+	w.Header().Set("X-Hook-ID", strconv.FormatUint(hookId, 10))
+}
+
+func getWebhookLog(w http.ResponseWriter, r *http.Request) {
+	// Get hook ID
+	id := path.Base(r.URL.Path)
+
+	// Get script location
+	hookName := path.Dir(strings.TrimPrefix(r.URL.Path, "/"))
+	_, err := hook.ResolveScript(scriptDir, hookName, defaultExt)
+	if err != nil {
+		slog.Error(err.Error())
+		http.Error(w, err.Error(), http.StatusNotFound)
+		return
+	}
+
+	// Retrieve log file
+	logFile, err := hook.GetLogFile(id, hookName, outputDir)
+	if err != nil {
+		slog.Error(err.Error())
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if logFile == nil {
+		http.Error(w, "hook execution log not found", http.StatusNotFound)
+		return
+	}
+	defer logFile.Close()
+
+	w.Header().Set("Content-Type", "text/plain")
+
+	io.Copy(w, logFile)
+}

+ 28 - 0
pkg/api/info.go

@@ -0,0 +1,28 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/ncarlier/webhookd/pkg/version"
+)
+
+// Info API informations model structure.
+type Info struct {
+	Name    string `json:"name"`
+	Version string `json:"version"`
+}
+
+func infoHandler(w http.ResponseWriter, r *http.Request) {
+	info := Info{
+		Name:    "webhookd",
+		Version: version.Version,
+	}
+	data, err := json.Marshal(info)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	w.Write(data)
+}

+ 23 - 0
pkg/api/router.go

@@ -0,0 +1,23 @@
+package api
+
+import (
+	"net/http"
+
+	"github.com/ncarlier/webhookd/pkg/config"
+)
+
+// NewRouter creates router with declared routes
+func NewRouter(conf *config.Config) *http.ServeMux {
+	router := http.NewServeMux()
+
+	// Register HTTP routes...
+	for _, route := range routes(conf) {
+		handler := route.HandlerFunc(conf)
+		for _, mw := range route.Middlewares {
+			handler = mw(handler)
+		}
+		router.Handle(route.Path, handler)
+	}
+
+	return router
+}

+ 70 - 0
pkg/api/routes.go

@@ -0,0 +1,70 @@
+package api
+
+import (
+	"log/slog"
+
+	"github.com/ncarlier/webhookd/pkg/auth"
+	"github.com/ncarlier/webhookd/pkg/config"
+	"github.com/ncarlier/webhookd/pkg/middleware"
+	"github.com/ncarlier/webhookd/pkg/truststore"
+)
+
+var commonMiddlewares = middleware.Middlewares{
+	middleware.XFF,
+	middleware.Cors,
+	middleware.Logger,
+	middleware.Tracing(nextRequestID),
+}
+
+func buildMiddlewares(conf *config.Config) middleware.Middlewares {
+	var middlewares = commonMiddlewares
+	if conf.TLS.Enabled {
+		middlewares = middlewares.UseAfter(middleware.HSTS)
+	}
+
+	// Load trust store...
+	ts, err := truststore.New(conf.TruststoreFile)
+	if err != nil {
+		slog.Warn("unable to load trust store", "filename", conf.TruststoreFile, "err", err)
+	}
+	if ts != nil {
+		middlewares = middlewares.UseAfter(middleware.Signature(ts))
+	}
+
+	// Load authenticator...
+	authenticator, err := auth.NewHtpasswdFromFile(conf.PasswdFile)
+	if err != nil {
+		slog.Debug("unable to load htpasswd file", "filename", conf.PasswdFile, "err", err)
+	}
+	if authenticator != nil {
+		middlewares = middlewares.UseAfter(middleware.AuthN(authenticator))
+	}
+	return middlewares
+}
+
+func routes(conf *config.Config) Routes {
+	middlewares := buildMiddlewares(conf)
+	staticPath := conf.Static.Path + "/"
+	return Routes{
+		route(
+			"/",
+			index,
+			middlewares...,
+		),
+		route(
+			staticPath,
+			static(staticPath),
+			middlewares.UseBefore(middleware.Methods("GET"))...,
+		),
+		route(
+			"/healthz",
+			healthz,
+			commonMiddlewares.UseBefore(middleware.Methods("GET"))...,
+		),
+		route(
+			"/varz",
+			varz,
+			middlewares.UseBefore(middleware.Methods("GET"))...,
+		),
+	}
+}

+ 19 - 0
pkg/api/static.go

@@ -0,0 +1,19 @@
+package api
+
+import (
+	"net/http"
+
+	"github.com/ncarlier/webhookd/pkg/config"
+)
+
+func static(prefix string) HandlerFunc {
+	return func(conf *config.Config) http.Handler {
+		if conf.Static.Dir != "" {
+			fs := http.FileServer(http.Dir(conf.Static.Dir))
+			return http.StripPrefix(prefix, fs)
+		}
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			http.Error(w, "404 page not found", http.StatusNotFound)
+		})
+	}
+}

+ 30 - 0
pkg/api/test/helper_test.go

@@ -0,0 +1,30 @@
+package test
+
+import (
+	"net/http"
+	"net/url"
+	"testing"
+
+	"github.com/ncarlier/webhookd/pkg/api"
+	"github.com/ncarlier/webhookd/pkg/assert"
+)
+
+func TestQueryParamsToShellVars(t *testing.T) {
+	tc := url.Values{
+		"string": []string{"foo"},
+		"list":   []string{"foo", "bar"},
+	}
+	values := api.HTTPParamsToShellVars(tc)
+	assert.Contains(t, "string=foo", values, "")
+	assert.Contains(t, "list=foo,bar", values, "")
+}
+
+func TestHTTPHeadersToShellVars(t *testing.T) {
+	tc := http.Header{
+		"Content-Type": []string{"text/plain"},
+		"X-Foo-Bar":    []string{"foo", "bar"},
+	}
+	values := api.HTTPParamsToShellVars(tc)
+	assert.Contains(t, "content_type=text/plain", values, "")
+	assert.Contains(t, "x_foo_bar=foo,bar", values, "")
+}

+ 29 - 0
pkg/api/types.go

@@ -0,0 +1,29 @@
+package api
+
+import (
+	"net/http"
+
+	"github.com/ncarlier/webhookd/pkg/config"
+	"github.com/ncarlier/webhookd/pkg/middleware"
+)
+
+// HandlerFunc custom function handler
+type HandlerFunc func(conf *config.Config) http.Handler
+
+// Route is the structure of an HTTP route definition
+type Route struct {
+	Path        string
+	HandlerFunc HandlerFunc
+	Middlewares middleware.Middlewares
+}
+
+// Routes is a list of Route
+type Routes []Route
+
+func route(path string, handler HandlerFunc, middlewares ...middleware.Middleware) Route {
+	return Route{
+		Path:        path,
+		HandlerFunc: handler,
+		Middlewares: middlewares,
+	}
+}

+ 25 - 0
pkg/api/varz.go

@@ -0,0 +1,25 @@
+package api
+
+import (
+	"expvar"
+	"fmt"
+	"net/http"
+
+	"github.com/ncarlier/webhookd/pkg/config"
+)
+
+func varz(conf *config.Config) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		fmt.Fprintf(w, "{\n")
+		first := true
+		expvar.Do(func(kv expvar.KeyValue) {
+			if !first {
+				fmt.Fprintf(w, ",\n")
+			}
+			first = false
+			fmt.Fprintf(w, "%q: %s", kv.Key, kv.Value)
+		})
+		fmt.Fprintf(w, "\n}\n")
+	})
+}

+ 68 - 0
pkg/assert/assert.go

@@ -0,0 +1,68 @@
+package assert
+
+import (
+	"testing"
+)
+
+// Nil assert that an object is nil
+func Nil(t *testing.T, actual interface{}, message string) {
+	if message == "" {
+		message = "Nil assertion failed"
+	}
+	if actual != nil {
+		t.Fatalf("%s - actual: %s", message, actual)
+	}
+}
+
+// NotNil assert that an object is not nil
+func NotNil(t *testing.T, actual interface{}, message string) {
+	if message == "" {
+		message = "Not nil assertion failed"
+	}
+	if actual == nil {
+		t.Fatalf("%s - actual: nil", message)
+	}
+}
+
+// Equal assert that an object is equal to an expected value
+func Equal[K comparable](t *testing.T, expected, actual K, message string) {
+	if message == "" {
+		message = "Equal assertion failed"
+	}
+	if actual != expected {
+		t.Fatalf("%s - expected: %v, actual: %v", message, expected, actual)
+	}
+}
+
+// NotEqual assert that an object is not equal to an expected value
+func NotEqual[K comparable](t *testing.T, expected, actual K, message string) {
+	if message == "" {
+		message = "Not equal assertion failed"
+	}
+	if actual == expected {
+		t.Fatalf("%s - unexpected: %v, actual: %v", message, expected, actual)
+	}
+}
+
+// ContainsStr assert that an array contains an expected value
+func Contains[K comparable](t *testing.T, expected K, array []K, message string) {
+	if message == "" {
+		message = "Array don't contains expected value"
+	}
+	for _, str := range array {
+		if str == expected {
+			return
+		}
+	}
+	t.Fatalf("%s - array: %v, expected value: %v", message, array, expected)
+}
+
+// True assert that an expression is true
+func True(t *testing.T, expression bool, message string) {
+	if message == "" {
+		message = "Expression is not true"
+	}
+	if !expression {
+		t.Fatalf("%s : %v", message, expression)
+	}
+}

+ 11 - 0
pkg/auth/authenticator.go

@@ -0,0 +1,11 @@
+package auth
+
+import (
+	"net/http"
+)
+
+// Authenticator is a generic interface to validate HTTP request credentials.
+// It's returns the authentication result along with the principal (username) if it has one.
+type Authenticator interface {
+	Validate(r *http.Request) (ok bool, username string)
+}

+ 81 - 0
pkg/auth/htpasswd-file.go

@@ -0,0 +1,81 @@
+package auth
+
+import (
+	"crypto/sha1"
+	"encoding/base64"
+	"encoding/csv"
+	"net/http"
+	"os"
+	"regexp"
+
+	"golang.org/x/crypto/bcrypt"
+)
+
+var (
+	shaRe = regexp.MustCompile(`^{SHA}`)
+	bcrRe = regexp.MustCompile(`^\$2b\$|^\$2a\$|^\$2y\$`)
+)
+
+// HtpasswdFile is a map for usernames to passwords.
+type HtpasswdFile struct {
+	path  string
+	users map[string]string
+}
+
+// NewHtpasswdFromFile reads the users and passwords from a htpasswd file and returns them.
+func NewHtpasswdFromFile(path string) (*HtpasswdFile, error) {
+	r, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	defer r.Close()
+
+	cr := csv.NewReader(r)
+	cr.Comma = ':'
+	cr.Comment = '#'
+	cr.TrimLeadingSpace = true
+
+	records, err := cr.ReadAll()
+	if err != nil {
+		return nil, err
+	}
+
+	users := make(map[string]string)
+	for _, record := range records {
+		users[record[0]] = record[1]
+	}
+
+	return &HtpasswdFile{
+		path:  path,
+		users: users,
+	}, nil
+}
+
+// Validate HTTP request credentials
+func (h *HtpasswdFile) Validate(r *http.Request) (ok bool, username string) {
+	username, password, ok := r.BasicAuth()
+	ok = ok && h.validateCredentials(username, password)
+	return
+}
+
+func (h *HtpasswdFile) validateCredentials(user, password string) bool {
+	pwd, exists := h.users[user]
+	if !exists {
+		return false
+	}
+
+	switch {
+	case shaRe.MatchString(pwd):
+		d := sha1.New()
+		_, _ = d.Write([]byte(password))
+		if pwd[5:] == base64.StdEncoding.EncodeToString(d.Sum(nil)) {
+			return true
+		}
+	case bcrRe.MatchString(pwd):
+		err := bcrypt.CompareHashAndPassword([]byte(pwd), []byte(password))
+		if err == nil {
+			return true
+		}
+	}
+	return false
+}

+ 27 - 0
pkg/auth/test/htpasswd-file_test.go

@@ -0,0 +1,27 @@
+package test
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/ncarlier/webhookd/pkg/assert"
+	"github.com/ncarlier/webhookd/pkg/auth"
+)
+
+func TestValidateCredentials(t *testing.T) {
+	htpasswdFile, err := auth.NewHtpasswdFromFile("test.htpasswd")
+	assert.Nil(t, err, ".htpasswd file should be loaded")
+	assert.NotNil(t, htpasswdFile, ".htpasswd file should be loaded")
+
+	req, err := http.NewRequest("POST", "http://localhost:8080", http.NoBody)
+	assert.Nil(t, err, "")
+	req.SetBasicAuth("foo", "bar")
+	ok, username := htpasswdFile.Validate(req)
+	assert.Equal(t, true, ok, "credentials should be valid")
+	assert.Equal(t, "foo", username, "invalid username")
+
+	req.SetBasicAuth("foo", "bad")
+	ok, username = htpasswdFile.Validate(req)
+	assert.Equal(t, false, ok, "credentials should be invalid")
+	assert.Equal(t, "foo", username, "invalid username")
+}

+ 2 - 0
pkg/auth/test/test.htpasswd

@@ -0,0 +1,2 @@
+# htpasswd -B -c test.htpasswd foo
+foo:$2y$05$068L1J0kA3FEh8jHSlnluut4gYleWd47Ig/AWztz8/8bQS6tHvtd.

+ 63 - 0
pkg/config/config.go

@@ -0,0 +1,63 @@
+package config
+
+import (
+	"fmt"
+	"regexp"
+)
+
+// Config store root configuration
+type Config struct {
+	ListenAddr     string             `flag:"listen-addr" desc:"HTTP listen address" default:":8080"`
+	PasswdFile     string             `flag:"passwd-file" desc:"Password file for basic HTTP authentication" default:".htpasswd"`
+	TruststoreFile string             `flag:"truststore-file" desc:"Truststore used by HTTP signature verifier (.pem or .p12)"`
+	Hook           HookConfig         `flag:"hook"`
+	Log            LogConfig          `flag:"log"`
+	Notification   NotificationConfig `flag:"notification"`
+	Static         StaticConfig       `flag:"static"`
+	TLS            TLSConfig          `flag:"tls"`
+	OldConfig      `flag:""`
+}
+
+// HookConfig store Hook execution configuration
+type HookConfig struct {
+	DefaultExt  string `flag:"default-ext" desc:"Default extension for hook scripts" default:"sh"`
+	DefaultMode string `flag:"default-mode" desc:"Hook default response mode (chuncked,buffered)" default:"chuncked"`
+	Timeout     int    `flag:"timeout" desc:"Maximum hook execution time in second" default:"10"`
+	ScriptsDir  string `flag:"scripts" desc:"Scripts location" default:"scripts"`
+	LogDir      string `flag:"log-dir" desc:"Hook execution logs location" default:""`
+	Workers     int    `flag:"workers" desc:"Number of workers to start" default:"2"`
+}
+
+// LogConfig store logger configuration
+type LogConfig struct {
+	Level   string   `flag:"level" desc:"Log level (debug, info, warn or error)" default:"info"`
+	Format  string   `flag:"format" desc:"Log format (json or text)" default:"text"`
+	Modules []string `flag:"modules" desc:"Logging modules to activate (http,hook)" default:""`
+}
+
+// NotificationConfig store notification configuration
+type NotificationConfig struct {
+	URI string `flag:"uri" desc:"Notification URI"`
+}
+
+// StaticConfig store static assets configuration
+type StaticConfig struct {
+	Dir  string `flag:"dir" desc:"Static file directory to serve on /static path" default:""`
+	Path string `flag:"path" desc:"Path to serve static file directory" default:"/static"`
+}
+
+// TLSConfig store TLS configuration
+type TLSConfig struct {
+	Enabled  bool   `flag:"enabled" desc:"Enable TLS" default:"false"`
+	CertFile string `flag:"cert-file" desc:"TLS certificate file (unused if ACME used)" default:"server.pem"`
+	KeyFile  string `flag:"key-file" desc:"TLS key file (unused if ACME used)" default:"server.key"`
+	Domain   string `flag:"domain" desc:"TLS domain name used by ACME"`
+}
+
+// Validate the configuration
+func (c *Config) Validate() error {
+	if matched, _ := regexp.MatchString(`^/\w+$`, c.Static.Path); !matched {
+		return fmt.Errorf("invalid static path: %s", c.Static.Path)
+	}
+	return nil
+}

+ 54 - 0
pkg/config/deprecated.go

@@ -0,0 +1,54 @@
+package config
+
+import (
+	"flag"
+	"log/slog"
+	"os"
+
+	"github.com/ncarlier/webhookd/pkg/helper"
+)
+
+// OldConfig contain global configuration
+type OldConfig struct {
+	NbWorkers int    `flag:"nb-workers" desc:"Number of workers to start [DEPRECATED]" default:"2"`
+	Scripts   string `flag:"scripts" desc:"Scripts location [DEPRECATED]" default:"scripts"`
+}
+
+// ManageDeprecatedFlags manage legacy configuration
+func (c *Config) ManageDeprecatedFlags(prefix string) {
+	if isUsingDeprecatedConfigParam(prefix, "nb-workers") {
+		c.Hook.Workers = c.NbWorkers
+	}
+	if isUsingDeprecatedConfigParam(prefix, "scripts") {
+		c.Hook.ScriptsDir = c.Scripts
+	}
+}
+
+func isUsingDeprecatedConfigParam(prefix, flagName string) bool {
+	envVar := helper.ToScreamingSnake(prefix + "_" + flagName)
+	switch {
+	case isFlagPassed(flagName):
+		slog.Warn("using deprecated configuration flag", "flag", flagName)
+		return true
+	case isEnvExists(envVar):
+		slog.Warn("using deprecated configuration environment variable", "variable", envVar)
+		return true
+	default:
+		return false
+	}
+}
+
+func isEnvExists(name string) bool {
+	_, exists := os.LookupEnv(name)
+	return exists
+}
+
+func isFlagPassed(name string) bool {
+	found := false
+	flag.Visit(func(f *flag.Flag) {
+		if f.Name == name {
+			found = true
+		}
+	})
+	return found
+}

+ 121 - 0
pkg/config/flag/bind.go

@@ -0,0 +1,121 @@
+package configflag
+
+import (
+	"flag"
+	"fmt"
+	"os"
+	"reflect"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/ncarlier/webhookd/pkg/helper"
+)
+
+// Bind conf struct tags with flags
+func Bind(conf interface{}, envPrefix string) error {
+	return bind(conf, envPrefix, "")
+}
+
+func bind(conf interface{}, envPrefix, keyPrefix string) error {
+	rv := reflect.ValueOf(conf)
+	for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
+		rv = rv.Elem()
+	}
+	typ := rv.Type()
+
+	for i := 0; i < typ.NumField(); i++ {
+		fieldType := typ.Field(i)
+		field := rv.Field(i)
+
+		var key, desc, val string
+		// Get field key from struct tag
+		if tag, ok := fieldType.Tag.Lookup("flag"); ok {
+			key = tag
+		} else {
+			continue
+		}
+		// Get field description from struct tag
+		if tag, ok := fieldType.Tag.Lookup("desc"); ok {
+			desc = tag
+		}
+		// Get field value from struct tag
+		if tag, ok := fieldType.Tag.Lookup("default"); ok {
+			val = tag
+		}
+
+		if keyPrefix != "" {
+			key = keyPrefix + "-" + key
+		}
+
+		// Get field value and description from environment variable
+		val = getEnvValue(envPrefix, key, val)
+		desc = getEnvDesc(envPrefix, key, desc)
+
+		// Get field value by reflection from struct definition
+		// And bind value to command line flag
+		switch fieldType.Type.Kind() {
+		case reflect.String:
+			field.SetString(val)
+			ptr, _ := field.Addr().Interface().(*string)
+			flag.StringVar(ptr, key, val, desc)
+		case reflect.Bool:
+			bVal, err := strconv.ParseBool(val)
+			if err != nil {
+				return fmt.Errorf("invalid boolean value for %s: %v", key, err)
+			}
+			field.SetBool(bVal)
+			ptr, _ := field.Addr().Interface().(*bool)
+			flag.BoolVar(ptr, key, bVal, desc)
+		case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+			if field.Kind() == reflect.Int64 && field.Type().PkgPath() == "time" && field.Type().Name() == "Duration" {
+				d, err := time.ParseDuration(val)
+				if err != nil {
+					return fmt.Errorf("invalid duration value for %s: %v", key, err)
+				}
+				field.SetInt(int64(d))
+				ptr, _ := field.Addr().Interface().(*time.Duration)
+				flag.DurationVar(ptr, key, d, desc)
+			} else {
+				i64Val, err := strconv.ParseInt(val, 0, fieldType.Type.Bits())
+				if err != nil {
+					return fmt.Errorf("invalid number value for %s: %v", key, err)
+				}
+				field.SetInt(i64Val)
+				ptr, _ := field.Addr().Interface().(*int)
+				flag.IntVar(ptr, key, int(i64Val), desc)
+			}
+		case reflect.Struct:
+			if err := bind(field.Addr().Interface(), envPrefix, key); err != nil {
+				return fmt.Errorf("invalid struct value for %s: %v", key, err)
+			}
+		case reflect.Slice:
+			sliceType := field.Type().Elem()
+			if sliceType.Kind() == reflect.String {
+				vals := strings.Split(val, ",")
+				sl := make([]string, len(vals))
+				copy(sl, vals)
+				field.Set(reflect.ValueOf(sl))
+				ptr, _ := field.Addr().Interface().(*[]string)
+				af := newArrayFlags(ptr)
+				flag.Var(af, key, desc)
+			}
+		}
+	}
+	return nil
+}
+
+func getEnvKey(prefix, key string) string {
+	return helper.ToScreamingSnake(prefix + "_" + key)
+}
+
+func getEnvValue(prefix, key, fallback string) string {
+	if value, ok := os.LookupEnv(getEnvKey(prefix, key)); ok {
+		return value
+	}
+	return fallback
+}
+
+func getEnvDesc(prefix, key, desc string) string {
+	return fmt.Sprintf("%s (env: %s)", desc, getEnvKey(prefix, key))
+}

+ 42 - 0
pkg/config/flag/test/bind_test.go

@@ -0,0 +1,42 @@
+package test
+
+import (
+	"flag"
+	"testing"
+	"time"
+
+	"github.com/ncarlier/webhookd/pkg/assert"
+	configflag "github.com/ncarlier/webhookd/pkg/config/flag"
+)
+
+type sampleConfig struct {
+	Label         string        `flag:"label" desc:"String parameter" default:"foo"`
+	Override      string        `flag:"override" desc:"String parameter to override" default:"bar"`
+	Count         int           `flag:"count" desc:"Number parameter" default:"2"`
+	Debug         bool          `flag:"debug" desc:"Boolean parameter" default:"false"`
+	Timer         time.Duration `flag:"timer" desc:"Duration parameter" default:"30s"`
+	Array         []string      `flag:"array" desc:"Array parameter" default:"foo,bar"`
+	OverrideArray []string      `flag:"override-array" desc:"Array parameter to override" default:"foo"`
+	Obj           objConfig     `flag:"obj"`
+}
+
+type objConfig struct {
+	Name string `flag:"name" desc:"Object name" default:"none"`
+}
+
+func TestFlagBinding(t *testing.T) {
+	conf := &sampleConfig{}
+	err := configflag.Bind(conf, "FOO")
+	flag.CommandLine.Parse([]string{"-override", "test", "-override-array", "a", "-override-array", "b", "-obj-name", "foo"})
+	assert.Nil(t, err, "error should be nil")
+	assert.Equal(t, "foo", conf.Label, "")
+	assert.Equal(t, "test", conf.Override, "")
+	assert.Equal(t, 2, conf.Count, "")
+	assert.Equal(t, false, conf.Debug, "")
+	assert.Equal(t, time.Second*30, conf.Timer, "")
+	assert.Equal(t, 2, len(conf.Array), "")
+	assert.Equal(t, "foo", conf.Array[0], "")
+	assert.Equal(t, 2, len(conf.OverrideArray), "")
+	assert.Equal(t, "a", conf.OverrideArray[0], "")
+	assert.Equal(t, "foo", conf.Obj.Name, "")
+}

+ 40 - 0
pkg/config/flag/types.go

@@ -0,0 +1,40 @@
+package configflag
+
+import "strings"
+
+// arrayFlags contains an array of command flags
+type arrayFlags struct {
+	items *[]string
+	reset bool
+}
+
+func newArrayFlags(items *[]string) *arrayFlags {
+	return &arrayFlags{
+		items: items,
+		reset: true,
+	}
+}
+
+// Values return the values of a flag array
+func (i *arrayFlags) Values() []string {
+	if i.items == nil {
+		return []string{}
+	}
+	return *i.items
+}
+
+// String return the string value of a flag array
+func (i *arrayFlags) String() string {
+	return strings.Join(i.Values(), ",")
+}
+
+// Set is used to add a value to the flag array
+func (i *arrayFlags) Set(value string) error {
+	if i.reset {
+		i.reset = false
+		*i.items = []string{value}
+	} else {
+		*i.items = append(*i.items, value)
+	}
+	return nil
+}

+ 151 - 0
pkg/helper/header/header.go

@@ -0,0 +1,151 @@
+// Copyright 2013 The Go Authors. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd.
+// Package header provides functions for parsing HTTP headers.
+package header
+
+import (
+	"net/http"
+	"strings"
+)
+
+var octetTypes [256]octetType
+
+type octetType byte
+
+const (
+	isToken octetType = 1 << iota
+	isSpace
+)
+
+func init() {
+	// OCTET      = <any 8-bit sequence of data>
+	// CHAR       = <any US-ASCII character (octets 0 - 127)>
+	// CTL        = <any US-ASCII control character (octets 0 - 31) and DEL (127)>
+	// CR         = <US-ASCII CR, carriage return (13)>
+	// LF         = <US-ASCII LF, linefeed (10)>
+	// SP         = <US-ASCII SP, space (32)>
+	// HT         = <US-ASCII HT, horizontal-tab (9)>
+	// <">        = <US-ASCII double-quote mark (34)>
+	// CRLF       = CR LF
+	// LWS        = [CRLF] 1*( SP | HT )
+	// TEXT       = <any OCTET except CTLs, but including LWS>
+	// separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" | <">
+	//              | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT
+	// token      = 1*<any CHAR except CTLs or separators>
+	// qdtext     = <any TEXT except <">>
+	for c := 0; c < 256; c++ {
+		var t octetType
+		isCtl := c <= 31 || c == 127
+		isChar := 0 <= c && c <= 127
+		isSeparator := strings.ContainsRune(" \t\"(),/:;<=>?@[]\\{}", rune(c))
+		if strings.ContainsRune(" \t\r\n", rune(c)) {
+			t |= isSpace
+		}
+		if isChar && !isCtl && !isSeparator {
+			t |= isToken
+		}
+		octetTypes[c] = t
+	}
+}
+
+// Copy returns a shallow copy of the header.
+func Copy(header http.Header) http.Header {
+	h := make(http.Header)
+	for k, vs := range header {
+		h[k] = vs
+	}
+	return h
+}
+
+// AcceptSpec describes an Accept* header.
+type AcceptSpec struct {
+	Value string
+	Q     float64
+}
+
+// ParseAccept parses Accept* headers.
+func ParseAccept(header http.Header, key string) (specs []AcceptSpec) {
+loop:
+	for _, s := range header[key] {
+		for {
+			var spec AcceptSpec
+			spec.Value, s = expectTokenSlash(s)
+			if spec.Value == "" {
+				continue loop
+			}
+			spec.Q = 1.0
+			s = skipSpace(s)
+			if strings.HasPrefix(s, ";") {
+				s = skipSpace(s[1:])
+				if !strings.HasPrefix(s, "q=") {
+					continue loop
+				}
+				spec.Q, s = expectQuality(s[2:])
+				if spec.Q < 0.0 {
+					continue loop
+				}
+			}
+			specs = append(specs, spec)
+			s = skipSpace(s)
+			if !strings.HasPrefix(s, ",") {
+				continue loop
+			}
+			s = skipSpace(s[1:])
+		}
+	}
+	return
+}
+
+func skipSpace(s string) (rest string) {
+	i := 0
+	for ; i < len(s); i++ {
+		if octetTypes[s[i]]&isSpace == 0 {
+			break
+		}
+	}
+	return s[i:]
+}
+
+func expectTokenSlash(s string) (token, rest string) {
+	i := 0
+	for ; i < len(s); i++ {
+		b := s[i]
+		if (octetTypes[b]&isToken == 0) && b != '/' {
+			break
+		}
+	}
+	return s[:i], s[i:]
+}
+
+func expectQuality(s string) (q float64, rest string) {
+	switch {
+	case len(s) == 0:
+		return -1, ""
+	case s[0] == '0':
+		q = 0
+	case s[0] == '1':
+		q = 1
+	default:
+		return -1, ""
+	}
+	s = s[1:]
+	if !strings.HasPrefix(s, ".") {
+		return q, s
+	}
+	s = s[1:]
+	i := 0
+	n := 0
+	d := 1
+	for ; i < len(s); i++ {
+		b := s[i]
+		if b < '0' || b > '9' {
+			break
+		}
+		n = n*10 + int(b) - '0'
+		d *= 10
+	}
+	return q + float64(n)/float64(d), s[i:]
+}

+ 64 - 0
pkg/helper/negociate.go

@@ -0,0 +1,64 @@
+package helper
+
+// Copyright 2013 The Go Authors. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd.
+
+import (
+	"net/http"
+	"strings"
+
+	"github.com/ncarlier/webhookd/pkg/helper/header"
+)
+
+// NegotiateContentType returns the best offered content type for the request's
+// Accept header. If two offers match with equal weight, then the more specific
+// offer is preferred.  For example, text/* trumps */*. If two offers match
+// with equal weight and specificity, then the offer earlier in the list is
+// preferred. If no offers match, then defaultOffer is returned.
+func NegotiateContentType(r *http.Request, offers []string, defaultOffer string) string {
+	bestOffer := defaultOffer
+	bestQ := -1.0
+	bestWild := 3
+	specs := header.ParseAccept(r.Header, "Accept")
+	for _, offer := range offers {
+		for _, spec := range specs {
+			switch {
+			case spec.Q == 0.0:
+				// ignore
+			case spec.Q < bestQ:
+				// better match found
+			case spec.Value == "*/*":
+				if spec.Q > bestQ || bestWild > 2 {
+					bestQ = spec.Q
+					bestWild = 2
+					bestOffer = offer
+				}
+			case strings.HasSuffix(spec.Value, "/*"):
+				if strings.HasPrefix(offer, spec.Value[:len(spec.Value)-1]) &&
+					(spec.Q > bestQ || bestWild > 1) {
+					bestQ = spec.Q
+					bestWild = 1
+					bestOffer = offer
+				}
+			case strings.HasSuffix(offer, "/*"):
+				if strings.HasPrefix(spec.Value, offer[:len(offer)-1]) &&
+					(spec.Q > bestQ || bestWild > 1) {
+					bestQ = spec.Q
+					bestWild = 1
+					bestOffer = spec.Value
+				}
+			default:
+				if spec.Value == offer &&
+					(spec.Q > bestQ || bestWild > 0) {
+					bestQ = spec.Q
+					bestWild = 0
+					bestOffer = offer
+				}
+			}
+		}
+	}
+	return bestOffer
+}

+ 93 - 0
pkg/helper/snake.go

@@ -0,0 +1,93 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2015 Ian Coleman
+ * Copyright (c) 2018 Ma_124, <github.com/Ma124>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, Subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or Substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package helper
+
+import (
+	"strings"
+)
+
+// ToSnake converts a string to snake_case
+func ToSnake(s string) string {
+	return ToDelimited(s, '_')
+}
+
+// ToScreamingSnake converts a string to SCREAMING_SNAKE_CASE
+func ToScreamingSnake(s string) string {
+	return ToScreamingDelimited(s, '_', true)
+}
+
+// ToKebab converts a string to kebab-case
+func ToKebab(s string) string {
+	return ToDelimited(s, '-')
+}
+
+// ToScreamingKebab converts a string to SCREAMING-KEBAB-CASE
+func ToScreamingKebab(s string) string {
+	return ToScreamingDelimited(s, '-', true)
+}
+
+// ToDelimited converts a string to delimited.snake.case (in this case `del = '.'`)
+func ToDelimited(s string, del uint8) string {
+	return ToScreamingDelimited(s, del, false)
+}
+
+// ToScreamingDelimited converts a string to SCREAMING.DELIMITED.SNAKE.CASE (in this case `del = '.'; screaming = true`) or delimited.snake.case (in this case `del = '.'; screaming = false`)
+func ToScreamingDelimited(s string, del uint8, screaming bool) string {
+	s = strings.Trim(s, " ")
+	n := ""
+	for i, v := range s {
+		// treat acronyms as words, eg for JSONData -> JSON is a whole word
+		nextCaseIsChanged := false
+		if i+1 < len(s) {
+			next := s[i+1]
+			if (v >= 'A' && v <= 'Z' && next >= 'a' && next <= 'z') || (v >= 'a' && v <= 'z' && next >= 'A' && next <= 'Z') {
+				nextCaseIsChanged = true
+			}
+		}
+
+		switch {
+		case i > 0 && n[len(n)-1] != del && nextCaseIsChanged:
+			// add underscore if next letter case type is changed
+			if v >= 'A' && v <= 'Z' {
+				n += string(del) + string(v)
+			} else if v >= 'a' && v <= 'z' {
+				n += string(v) + string(del)
+			}
+		case v == ' ' || v == '_' || v == '-' || v == '/':
+			// replace spaces/underscores with delimiters
+			n += string(del)
+		default:
+			n += string(v)
+		}
+	}
+
+	if screaming {
+		n = strings.ToUpper(n)
+	} else {
+		n = strings.ToLower(n)
+	}
+	return n
+}

+ 25 - 0
pkg/helper/test/snake_test.go

@@ -0,0 +1,25 @@
+package test
+
+import (
+	"testing"
+
+	"github.com/ncarlier/webhookd/pkg/assert"
+	"github.com/ncarlier/webhookd/pkg/helper"
+)
+
+func TestToSnakeCase(t *testing.T) {
+	testCases := []struct {
+		value    string
+		expected string
+	}{
+		{"hello-world", "hello_world"},
+		{"helloWorld", "hello_world"},
+		{"HelloWorld", "hello_world"},
+		{"Hello/_World", "hello__world"},
+		{"Hello/world", "hello_world"},
+	}
+	for _, tc := range testCases {
+		value := helper.ToSnake(tc.value)
+		assert.Equal(t, tc.expected, value, "")
+	}
+}

+ 14 - 0
pkg/helper/values.go

@@ -0,0 +1,14 @@
+package helper
+
+import (
+	"net/url"
+	"strings"
+)
+
+// GetValueOrAlt get value or alt
+func GetValueOrAlt(values url.Values, key, alt string) string {
+	if val, ok := values[key]; ok {
+		return strings.Join(val, ",")
+	}
+	return alt
+}

+ 24 - 0
pkg/hook/helper.go

@@ -0,0 +1,24 @@
+package hook
+
+import (
+	"errors"
+	"os"
+	"path"
+	"strings"
+)
+
+// ResolveScript is resolving the target script.
+func ResolveScript(dir, name, defaultExt string) (string, error) {
+	if path.Ext(name) == "" {
+		name += "." + defaultExt
+	}
+	script := path.Clean(path.Join(dir, name))
+	if !strings.HasPrefix(script, dir) {
+		return "", errors.New("Invalid script path: " + name)
+	}
+	if _, err := os.Stat(script); os.IsNotExist(err) {
+		return "", errors.New("Script not found: " + script)
+	}
+
+	return script, nil
+}

+ 297 - 0
pkg/hook/job.go

@@ -0,0 +1,297 @@
+package hook
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"io"
+	"log/slog"
+	"os"
+	"os/exec"
+	"path"
+	"strconv"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"syscall"
+	"time"
+
+	"github.com/ncarlier/webhookd/pkg/helper"
+	"github.com/ncarlier/webhookd/pkg/logger"
+)
+
+var hookID uint64
+
+// Job a hook job
+type Job struct {
+	id          uint64
+	name        string
+	script      string
+	method      string
+	payload     string
+	args        []string
+	MessageChan chan []byte
+	timeout     int
+	start       time.Time
+	status      Status
+	logFilename string
+	err         error
+	exitCode    int
+	mutex       sync.Mutex
+}
+
+// NewHookJob creates new hook job
+func NewHookJob(request *Request) (*Job, error) {
+	job := &Job{
+		id:          atomic.AddUint64(&hookID, 1),
+		name:        request.Name,
+		script:      request.Script,
+		method:      request.Method,
+		payload:     request.Payload,
+		args:        request.Args,
+		timeout:     request.Timeout,
+		MessageChan: make(chan []byte),
+		status:      Idle,
+	}
+	job.logFilename = path.Join(request.OutputDir, fmt.Sprintf("%s_%d_%s.txt", helper.ToSnake(job.name), job.id, time.Now().Format("20060102_1504")))
+	return job, nil
+}
+
+// ID returns job ID
+func (job *Job) ID() uint64 {
+	return job.id
+}
+
+// Name returns job name
+func (job *Job) Name() string {
+	return job.name
+}
+
+// Err returns job error
+func (job *Job) Err() error {
+	return job.err
+}
+
+// Meta returns job meta
+func (job *Job) Meta() []string {
+	return []string{
+		"hook_id=" + strconv.FormatUint(job.id, 10),
+		"hook_name=" + job.name,
+		"hook_method=" + job.method,
+	}
+}
+
+// Terminate set job as terminated
+func (job *Job) Terminate(err error) error {
+	job.mutex.Lock()
+	defer job.mutex.Unlock()
+	job.status = Success
+
+	if err != nil {
+		if exiterr, ok := err.(*exec.ExitError); ok {
+			job.exitCode = exiterr.ExitCode()
+		}
+		job.status = Error
+		job.err = err
+		slog.Error(
+			"hook executed",
+			"hook", job.Name(),
+			"id", job.ID(),
+			"status", "error",
+			"exitCode", job.exitCode,
+			"err", err,
+			"took", time.Since(job.start).Milliseconds(),
+		)
+		return err
+	}
+	slog.Info(
+		"hook executed",
+		"hook", job.Name(),
+		"id", job.ID(),
+		"status", "success",
+		"took", time.Since(job.start).Milliseconds(),
+	)
+	return nil
+}
+
+// IsTerminated ask if the job is terminated
+func (job *Job) IsTerminated() bool {
+	job.mutex.Lock()
+	defer job.mutex.Unlock()
+	return job.status == Success || job.status == Error
+}
+
+// Status get job status
+func (job *Job) Status() Status {
+	return job.status
+}
+
+// StatusLabel return job status as string
+func (job *Job) StatusLabel() string {
+	switch job.status {
+	case Error:
+		return "error"
+	case Success:
+		return "success"
+	case Running:
+		return "running"
+	default:
+		return "idle"
+	}
+}
+
+// ExitCode of the underlying process job
+// Can be 0 if the process is not over
+func (job *Job) ExitCode() int {
+	return job.exitCode
+}
+
+// SendMessage send message to the message channel
+func (job *Job) SendMessage(message string) {
+	job.MessageChan <- []byte(message)
+}
+
+// OpenLogFile open job log file
+func (job *Job) OpenLogFile() (*os.File, error) {
+	return os.Open(job.logFilename)
+}
+
+// Logs returns job logs filtered with the prefix
+func (job *Job) Logs(prefixFilter string) string {
+	file, err := job.OpenLogFile()
+	if err != nil {
+		return err.Error()
+	}
+	defer file.Close()
+
+	var result bytes.Buffer
+	scanner := bufio.NewScanner(file)
+	for scanner.Scan() {
+		line := scanner.Text()
+		if strings.HasPrefix(line, prefixFilter) {
+			line = strings.TrimPrefix(line, prefixFilter)
+			line = strings.TrimLeft(line, " ")
+			result.WriteString(line + "\n")
+		}
+	}
+	if err := scanner.Err(); err != nil {
+		return err.Error()
+	}
+	return result.String()
+}
+
+// Close job message chan
+func (job *Job) Close() {
+	close(job.MessageChan)
+}
+
+// Run hook job
+func (job *Job) Run() error {
+	if job.status != Idle {
+		return fmt.Errorf("unable to run job: status=%s", job.StatusLabel())
+	}
+	job.status = Running
+	job.start = time.Now()
+	slog.Info("executing hook...", "hook", job.name, "id", job.id)
+
+	binary, err := exec.LookPath(job.script)
+	if err != nil {
+		return job.Terminate(err)
+	}
+
+	// Exec script with parameter...
+	cmd := exec.Command(binary, job.payload)
+	// with env variables and hook arguments...
+	cmd.Env = append(os.Environ(), job.args...)
+	// and hook meta...
+	cmd.Env = append(cmd.Env, job.Meta()...)
+	// using a process group...
+	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+
+	// Open the log file for writing
+	logFile, err := os.Create(job.logFilename)
+	if err != nil {
+		return job.Terminate(err)
+	}
+	defer logFile.Close()
+	slog.Debug("hook details", "hook", job.name, "id", job.id, "script", job.script, "args", job.args, "output", logFile.Name())
+
+	wLogFile := bufio.NewWriter(logFile)
+	defer wLogFile.Flush()
+
+	// Combine cmd stdout and stderr
+	outReader, err := cmd.StdoutPipe()
+	if err != nil {
+		return job.Terminate(err)
+	}
+	errReader, err := cmd.StderrPipe()
+	if err != nil {
+		return job.Terminate(err)
+	}
+	cmdReader := io.MultiReader(outReader, errReader)
+
+	// Start the script...
+	err = cmd.Start()
+	if err != nil {
+		return job.Terminate(err)
+	}
+
+	// Create wait group to wait for command output completion
+	var wg sync.WaitGroup
+	wg.Add(1)
+
+	// Write script output to log file and the work message channel
+	go func(reader io.Reader) {
+		r := bufio.NewReader(reader)
+		for {
+			line, err := r.ReadString('\n')
+			if err != nil {
+				if err == io.EOF {
+					break
+				}
+				slog.Error("error while reading hook std[out/err]", "hook", job.name, "id", job.id, "err", err)
+				break
+			}
+			line, _ = strings.CutSuffix(line, "\r")
+			// writing to the work channel
+			if !job.IsTerminated() {
+				job.MessageChan <- []byte(line)
+			} else {
+				slog.Error("hook execution done ; unable to write more data into the channel", "hook", job.name, "id", job.id, "line", line)
+				break
+			}
+			// write to stdout if configured
+			logger.LogIf(
+				logger.HookOutputEnabled,
+				slog.LevelInfo+1,
+				line,
+				"hook", job.name,
+				"id", job.id,
+			)
+			// writing to outfile
+			if _, err := wLogFile.WriteString(line + "\n"); err != nil {
+				slog.Error("error while writing into the log file", "filename", logFile.Name(), "err", err)
+				break
+			}
+		}
+		wg.Done()
+	}(cmdReader)
+
+	// Start timeout timer
+	timer := time.AfterFunc(time.Duration(job.timeout)*time.Second, func() {
+		slog.Warn("hook has timed out: killing process...", "hook", job.name, "id", job.id, "timeout", job.timeout, "pid", cmd.Process.Pid)
+		syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+	})
+
+	// Wait for command output completion
+	wg.Wait()
+
+	// Wait for command completion
+	err = cmd.Wait()
+
+	// Stop timeout timer
+	timer.Stop()
+
+	// Mark work as terminated
+	return job.Terminate(err)
+}

+ 24 - 0
pkg/hook/logs.go

@@ -0,0 +1,24 @@
+package hook
+
+import (
+	"fmt"
+	"os"
+	"path"
+	"path/filepath"
+
+	"github.com/ncarlier/webhookd/pkg/helper"
+)
+
+// GetLogFile get hook log with its name and id
+func GetLogFile(id, name, base string) (*os.File, error) {
+	logPattern := path.Join(base, fmt.Sprintf("%s_%s_*.txt", helper.ToSnake(name), id))
+	files, err := filepath.Glob(logPattern)
+	if err != nil {
+		return nil, err
+	}
+	if len(files) > 0 {
+		filename := files[len(files)-1]
+		return os.Open(filename)
+	}
+	return nil, nil
+}

+ 32 - 0
pkg/hook/test/helper_test.go

@@ -0,0 +1,32 @@
+package test
+
+import (
+	"testing"
+
+	"github.com/ncarlier/webhookd/pkg/assert"
+	"github.com/ncarlier/webhookd/pkg/hook"
+)
+
+func TestResolveScript(t *testing.T) {
+	script, err := hook.ResolveScript("../../../scripts", "../scripts/echo", "sh")
+	assert.Nil(t, err, "")
+	assert.Equal(t, "../../../scripts/echo.sh", script, "")
+}
+
+func TestNotResolveScript(t *testing.T) {
+	_, err := hook.ResolveScript("../../scripts", "foo", "sh")
+	assert.NotNil(t, err, "")
+	assert.Equal(t, "Script not found: ../../scripts/foo.sh", err.Error(), "")
+}
+
+func TestResolveBadScript(t *testing.T) {
+	_, err := hook.ResolveScript("../../scripts", "../tests/test_simple", "sh")
+	assert.NotNil(t, err, "")
+	assert.Equal(t, "Invalid script path: ../tests/test_simple.sh", err.Error(), "")
+}
+
+func TestResolveScriptWithExtension(t *testing.T) {
+	_, err := hook.ResolveScript("../../scripts", "node.js", "sh")
+	assert.NotNil(t, err, "")
+	assert.Equal(t, "Script not found: ../../scripts/node.js", err.Error(), "")
+}

+ 94 - 0
pkg/hook/test/job_test.go

@@ -0,0 +1,94 @@
+package test
+
+import (
+	"log/slog"
+	"os"
+	"strconv"
+	"testing"
+
+	"github.com/ncarlier/webhookd/pkg/assert"
+	"github.com/ncarlier/webhookd/pkg/hook"
+)
+
+func printJobMessages(job *hook.Job) {
+	go func() {
+		for {
+			msg, open := <-job.MessageChan
+			if !open {
+				break
+			}
+			slog.Info(string(msg))
+		}
+	}()
+}
+
+func TestHookJob(t *testing.T) {
+	req := &hook.Request{
+		Name:    "test_simple",
+		Script:  "../test/test_simple.sh",
+		Method:  "GET",
+		Payload: "{\"foo\": \"bar\"}",
+		Args: []string{
+			"name=foo",
+			"user_agent=test",
+		},
+		Timeout:   5,
+		OutputDir: os.TempDir(),
+	}
+	job, err := hook.NewHookJob(req)
+	assert.Nil(t, err, "")
+	assert.NotNil(t, job, "")
+	printJobMessages(job)
+	err = job.Run()
+	assert.Nil(t, err, "")
+	assert.Equal(t, job.Status(), hook.Success, "")
+	assert.Equal(t, job.Logs("notify:"), "OK\n", "")
+
+	// Test that we can retrieve log file afterward
+	id := strconv.FormatUint(job.ID(), 10)
+	logFile, err := hook.GetLogFile(id, "test", os.TempDir())
+	assert.Nil(t, err, "Log file should exists")
+	defer logFile.Close()
+	assert.NotNil(t, logFile, "Log file should be retrieve")
+}
+
+func TestWorkRunnerWithError(t *testing.T) {
+	req := &hook.Request{
+		Name:      "test_error",
+		Script:    "../test/test_error.sh",
+		Method:    "POST",
+		Payload:   "",
+		Args:      []string{},
+		Timeout:   5,
+		OutputDir: os.TempDir(),
+	}
+	job, err := hook.NewHookJob(req)
+	assert.Nil(t, err, "")
+	assert.NotNil(t, job, "")
+	printJobMessages(job)
+	err = job.Run()
+	assert.NotNil(t, err, "")
+	assert.Equal(t, job.Status(), hook.Error, "")
+	assert.Equal(t, "exit status 1", err.Error(), "")
+	assert.Equal(t, 1, job.ExitCode(), "")
+}
+
+func TestWorkRunnerWithTimeout(t *testing.T) {
+	req := &hook.Request{
+		Name:      "test_timeout",
+		Script:    "../test/test_timeout.sh",
+		Method:    "POST",
+		Payload:   "",
+		Args:      []string{},
+		Timeout:   1,
+		OutputDir: os.TempDir(),
+	}
+	job, err := hook.NewHookJob(req)
+	assert.Nil(t, err, "")
+	assert.NotNil(t, job, "")
+	printJobMessages(job)
+	err = job.Run()
+	assert.NotNil(t, err, "")
+	assert.Equal(t, job.Status(), hook.Error, "")
+	assert.Equal(t, "signal: killed", err.Error(), "")
+}

+ 6 - 0
pkg/hook/test/test_error.sh

@@ -0,0 +1,6 @@
+#!/bin/sh
+
+echo "Running error test script..."
+
+echo "Expected error"
+exit 1

+ 19 - 0
pkg/hook/test/test_simple.sh

@@ -0,0 +1,19 @@
+#!/bin/sh
+
+echo "Running simple test script..."
+
+echo "Testing parameters..."
+[ -z "$name" ] && echo "Name variable undefined" && exit 1
+[ -z "$user_agent" ] && echo "User-Agent variable undefined" && exit 1
+[ "$user_agent" != "test" ] && echo "Invalid User-Agent variable: $user_agent" && exit 1
+[ -z "$hook_id" ] && echo "Hook ID variable undefined" && exit 1
+[ "$hook_name" != "test_simple" ] && echo "Invalid hook name variable: $hook_name" && exit 1
+[ "$hook_method" != "GET" ] && echo "Invalid hook method variable: $hook_method" && exit 1
+
+echo "Testing payload..."
+[ -z "$1" ] && echo "Payload undefined" && exit 1
+[ "$1" != "{\"foo\": \"bar\"}" ] && echo "Invalid payload: $1" && exit 1
+
+echo "notify: OK"
+
+exit 0

+ 12 - 0
pkg/hook/test/test_timeout.sh

@@ -0,0 +1,12 @@
+#!/bin/sh
+
+echo "Running timeout test script..."
+
+for i in `seq 5`; do
+  sleep .5
+  echo "running..."
+done
+
+echo "This line should not be executed!"
+
+exit 0

+ 26 - 0
pkg/hook/types.go

@@ -0,0 +1,26 @@
+package hook
+
+// Status is the status of a hook
+type Status int
+
+const (
+	// Idle means that the hook is not yet started
+	Idle Status = iota
+	// Running means that the hook is running
+	Running
+	// Success means that the hook over
+	Success
+	// Error means that the hook is over but in error
+	Error
+)
+
+// Request is a hook request
+type Request struct {
+	Name      string
+	Script    string
+	Method    string
+	Payload   string
+	Args      []string
+	Timeout   int
+	OutputDir string
+}

+ 48 - 0
pkg/logger/logger.go

@@ -0,0 +1,48 @@
+package logger
+
+import (
+	"context"
+	"log/slog"
+	"os"
+)
+
+var (
+	// HookOutputEnabled writes hook output into logs if true
+	HookOutputEnabled = false
+	// RequestOutputEnabled writes HTTP request into logs if true
+	RequestOutputEnabled = false
+)
+
+// Configure logger
+func Configure(format, level string) {
+	logLevel := slog.LevelDebug
+	switch level {
+	case "info":
+		logLevel = slog.LevelInfo
+	case "warn":
+		logLevel = slog.LevelWarn
+	case "error":
+		logLevel = slog.LevelError
+	}
+
+	opts := slog.HandlerOptions{
+		Level:     logLevel,
+		AddSource: logLevel == slog.LevelDebug,
+	}
+
+	var logger *slog.Logger
+	if format == "json" {
+		logger = slog.New(slog.NewJSONHandler(os.Stdout, &opts))
+	} else {
+		logger = slog.New(slog.NewTextHandler(os.Stdout, &opts))
+	}
+
+	slog.SetDefault(logger)
+}
+
+// LogIf writ log on condition
+func LogIf(condition bool, level slog.Level, msg string, args ...any) {
+	if condition {
+		slog.Log(context.Background(), level, msg, args...)
+	}
+}

+ 35 - 0
pkg/metric/metric.go

@@ -0,0 +1,35 @@
+package metric
+
+import (
+	"expvar"
+	"runtime"
+	"time"
+)
+
+var startTime = time.Now().UTC()
+
+func goroutines() interface{} {
+	return runtime.NumGoroutine()
+}
+
+// uptime is an expvar.Func compliant wrapper for uptime info.
+func uptime() interface{} {
+	uptime := time.Since(startTime)
+	return int64(uptime)
+}
+
+var stats = expvar.NewMap("hookstats")
+
+var (
+	// Requests count the number of request
+	Requests expvar.Int
+	// RequestsFailed count the number of failed request
+	RequestsFailed expvar.Int
+)
+
+func init() {
+	stats.Set("requests", &Requests)
+	stats.Set("requests_failed", &RequestsFailed)
+	expvar.Publish("goroutines", expvar.Func(goroutines))
+	expvar.Publish("uptime", expvar.Func(uptime))
+}

+ 26 - 0
pkg/middleware/authn.go

@@ -0,0 +1,26 @@
+package middleware
+
+import (
+	"net/http"
+
+	"github.com/ncarlier/webhookd/pkg/auth"
+)
+
+const xWebAuthUser = "X-WebAuth-User"
+
+// AuthN is a middleware to checks HTTP request credentials
+func AuthN(authenticator auth.Authenticator) Middleware {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			r.Header.Del(xWebAuthUser)
+			if ok, username := authenticator.Validate(r); ok {
+				r.Header.Set(xWebAuthUser, username)
+				next.ServeHTTP(w, r)
+				return
+			}
+			w.Header().Set("WWW-Authenticate", `Basic realm="Ah ah ah, you didn't say the magic word"`)
+			w.WriteHeader(401)
+			w.Write([]byte("401 Unauthorized\n"))
+		})
+	}
+}

+ 18 - 0
pkg/middleware/cors.go

@@ -0,0 +1,18 @@
+package middleware
+
+import (
+	"net/http"
+)
+
+// Cors is a middleware to enabling CORS on HTTP requests
+func Cors(inner http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Access-Control-Allow-Origin", "*")
+		w.Header().Set("Access-Control-Allow-Methods", "*")
+		w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization")
+
+		if r.Method != "OPTIONS" {
+			inner.ServeHTTP(w, r)
+		}
+	})
+}

+ 13 - 0
pkg/middleware/hsts.go

@@ -0,0 +1,13 @@
+package middleware
+
+import (
+	"net/http"
+)
+
+// HSTS is a middleware to enabling HSTS on HTTP requests
+func HSTS(inner http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Strict-Transport-Security", "max-age=15768000 ; includeSubDomains")
+		inner.ServeHTTP(w, r)
+	})
+}

+ 92 - 0
pkg/middleware/logger.go

@@ -0,0 +1,92 @@
+package middleware
+
+import (
+	"fmt"
+	"log/slog"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/ncarlier/webhookd/pkg/logger"
+)
+
+type key int
+
+const (
+	requestIDKey key = 0
+)
+
+// Logger is a middleware to log HTTP request
+func Logger(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		o := &responseObserver{ResponseWriter: w}
+		start := time.Now()
+		defer func() {
+			requestID, ok := r.Context().Value(requestIDKey).(string)
+			if !ok {
+				requestID = "0"
+			}
+			logger.LogIf(
+				logger.RequestOutputEnabled,
+				slog.LevelInfo+1,
+				fmt.Sprintf("%s %s %s", r.Method, r.URL, r.Proto),
+				"ip", getRequestIP(r),
+				"time", start.Format("02/Jan/2006:15:04:05 -0700"),
+				"duration", time.Since(start).Milliseconds(),
+				"status", o.status,
+				"bytes", o.written,
+				"referer", r.Referer(),
+				"ua", r.UserAgent(),
+				"reqid", requestID,
+			)
+		}()
+		next.ServeHTTP(o, r)
+	})
+}
+
+func getRequestIP(r *http.Request) string {
+	ip := r.Header.Get("X-Forwarded-For")
+	if ip == "" {
+		ip = r.RemoteAddr
+	}
+	if comma := strings.Index(ip, ","); comma != -1 {
+		ip = ip[0:comma]
+	}
+	if colon := strings.LastIndex(ip, ":"); colon != -1 {
+		ip = ip[:colon]
+	}
+
+	return ip
+}
+
+type responseObserver struct {
+	http.ResponseWriter
+	status      int
+	written     int64
+	wroteHeader bool
+}
+
+func (o *responseObserver) Write(p []byte) (n int, err error) {
+	if !o.wroteHeader {
+		o.WriteHeader(http.StatusOK)
+	}
+	n, err = o.ResponseWriter.Write(p)
+	o.written += int64(n)
+	return
+}
+
+func (o *responseObserver) WriteHeader(code int) {
+	o.ResponseWriter.WriteHeader(code)
+	if o.wroteHeader {
+		return
+	}
+	o.wroteHeader = true
+	o.status = code
+}
+
+func (o *responseObserver) Flush() {
+	flusher, ok := o.ResponseWriter.(http.Flusher)
+	if ok {
+		flusher.Flush()
+	}
+}

+ 24 - 0
pkg/middleware/methods.go

@@ -0,0 +1,24 @@
+package middleware
+
+import (
+	"net/http"
+)
+
+// Methods is a middleware to check that the request use the correct HTTP method
+func Methods(methods ...string) Middleware {
+	return func(next http.Handler) http.Handler {
+		allowedMethods := make(map[string]struct{}, len(methods))
+		for _, s := range methods {
+			allowedMethods[s] = struct{}{}
+		}
+
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			if _, ok := allowedMethods[r.Method]; ok {
+				next.ServeHTTP(w, r)
+				return
+			}
+			w.WriteHeader(405)
+			w.Write([]byte("405 Method Not Allowed\n"))
+		})
+	}
+}

+ 26 - 0
pkg/middleware/signature.go

@@ -0,0 +1,26 @@
+package middleware
+
+import (
+	"net/http"
+
+	"github.com/ncarlier/webhookd/pkg/middleware/signature"
+	"github.com/ncarlier/webhookd/pkg/truststore"
+)
+
+// Signature is a middleware to checks HTTP request signature
+func Signature(ts truststore.TrustStore) Middleware {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			handler := signature.HTTPSignatureHandler
+			if signature.IsEd25519SignatureRequest(r.Header) {
+				handler = signature.Ed25519SignatureHandler
+			}
+			if err := handler(r, ts); err != nil {
+				w.WriteHeader(401)
+				w.Write([]byte("401 Unauthorized: " + err.Error()))
+				return
+			}
+			next.ServeHTTP(w, r)
+		})
+	}
+}

+ 70 - 0
pkg/middleware/signature/ed25519-signature.go

@@ -0,0 +1,70 @@
+package signature
+
+import (
+	"bytes"
+	"crypto/ed25519"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+
+	"github.com/ncarlier/webhookd/pkg/truststore"
+)
+
+const (
+	defaultKeyID        = "default"
+	xSignatureEd25519   = "X-Signature-Ed25519"
+	xSignatureTimestamp = "X-Signature-Timestamp"
+)
+
+// IsEd25519SignatureRequest test if HTTP headers contains Ed25519 Signature
+func IsEd25519SignatureRequest(headers http.Header) bool {
+	return headers.Get(xSignatureEd25519) != ""
+}
+
+// Ed25519SignatureHandler validate request HTTP signature
+func Ed25519SignatureHandler(r *http.Request, ts truststore.TrustStore) error {
+	pubkey := ts.GetPublicKey(defaultKeyID)
+	if pubkey == nil {
+		return fmt.Errorf("public key not found: %s", defaultKeyID)
+	}
+
+	key, ok := pubkey.(ed25519.PublicKey)
+	if !ok {
+		return errors.New("invalid public key: verify the algorithm")
+	}
+
+	value := r.Header.Get(xSignatureEd25519)
+	timestamp := r.Header.Get(xSignatureTimestamp)
+	if value == "" || timestamp == "" {
+		return errors.New("missing signature header")
+	}
+
+	sig, err := hex.DecodeString(value)
+	if err != nil || len(sig) != ed25519.SignatureSize || sig[63]&224 != 0 {
+		return fmt.Errorf("invalid signature format: %s", sig)
+	}
+
+	var msg bytes.Buffer
+	msg.WriteString(timestamp)
+
+	defer r.Body.Close()
+	var body bytes.Buffer
+
+	// Copy the original body back into the request after finishing.
+	defer func() {
+		r.Body = io.NopCloser(&body)
+	}()
+
+	// Copy body into buffers
+	_, err = io.Copy(&msg, io.TeeReader(r.Body, &body))
+	if err != nil {
+		return err
+	}
+
+	if !ed25519.Verify(key, msg.Bytes(), sig) {
+		return errors.New("invalid signature")
+	}
+	return nil
+}

+ 28 - 0
pkg/middleware/signature/http-signature.go

@@ -0,0 +1,28 @@
+package signature
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/go-fed/httpsig"
+	"github.com/ncarlier/webhookd/pkg/truststore"
+)
+
+// HTTPSignatureHandler validate request HTTP signature
+func HTTPSignatureHandler(r *http.Request, ts truststore.TrustStore) error {
+	verifier, err := httpsig.NewVerifier(r)
+	if err != nil {
+		return err
+	}
+	pubkeyID := verifier.KeyId()
+	pubkey := ts.GetPublicKey(pubkeyID)
+	if pubkey == nil {
+		return fmt.Errorf("public key not found: %s", pubkeyID)
+	}
+	// TODO dynamic algo
+	err = verifier.Verify(pubkey, httpsig.RSA_SHA256)
+	if err != nil {
+		return err
+	}
+	return nil
+}

+ 47 - 0
pkg/middleware/signature/test/ed5519-signature_test.go

@@ -0,0 +1,47 @@
+package test
+
+import (
+	"bytes"
+	"crypto"
+	"crypto/ed25519"
+	"crypto/rand"
+	"encoding/hex"
+	"net/http"
+	"strconv"
+	"testing"
+	"time"
+
+	"github.com/ncarlier/webhookd/pkg/assert"
+	"github.com/ncarlier/webhookd/pkg/middleware/signature"
+	"github.com/ncarlier/webhookd/pkg/truststore"
+)
+
+func TestEd5519Signature(t *testing.T) {
+	pubkey, privkey, err := ed25519.GenerateKey(rand.Reader)
+	assert.Nil(t, err, "")
+
+	ts := &truststore.InMemoryTrustStore{
+		Keys: map[string]crypto.PublicKey{
+			"default": pubkey,
+		},
+	}
+
+	body := "this is a test"
+	req, err := http.NewRequest("POST", "/", bytes.NewBufferString(body))
+	assert.Nil(t, err, "")
+
+	now := time.Now()
+	timestamp := strconv.FormatInt(now.Unix(), 10)
+
+	var msg bytes.Buffer
+	msg.WriteString(timestamp)
+	msg.WriteString(body)
+	s := ed25519.Sign(privkey, msg.Bytes())
+	req.Header.Set("X-Signature-Ed25519", hex.EncodeToString(s[:ed25519.SignatureSize]))
+	req.Header.Set("X-Signature-Timestamp", timestamp)
+	req.Header.Add("date", now.UTC().Format(http.TimeFormat))
+	req.Header.Set("Content-Type", "text/plain")
+
+	err = signature.Ed25519SignatureHandler(req, ts)
+	assert.Nil(t, err, "")
+}

+ 47 - 0
pkg/middleware/signature/test/http-signature_test.go

@@ -0,0 +1,47 @@
+package test
+
+import (
+	"crypto"
+	"crypto/rand"
+	"crypto/rsa"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/go-fed/httpsig"
+	"github.com/ncarlier/webhookd/pkg/assert"
+	"github.com/ncarlier/webhookd/pkg/middleware/signature"
+	"github.com/ncarlier/webhookd/pkg/truststore"
+)
+
+func assertSigner(t *testing.T) httpsig.Signer {
+	prefs := []httpsig.Algorithm{httpsig.RSA_SHA256}
+	digestAlgorithm := httpsig.DigestSha256
+	headers := []string{httpsig.RequestTarget, "date"}
+	signer, _, err := httpsig.NewSigner(prefs, digestAlgorithm, headers, httpsig.Signature, 0)
+	assert.Nil(t, err, "")
+	return signer
+}
+
+func TestHTTPSignature(t *testing.T) {
+	privkey, err := rsa.GenerateKey(rand.Reader, 2048)
+	assert.Nil(t, err, "")
+	pubkey := &privkey.PublicKey
+
+	ts := &truststore.InMemoryTrustStore{
+		Keys: map[string]crypto.PublicKey{
+			"default": pubkey,
+		},
+	}
+
+	signer := assertSigner(t)
+	var body []byte
+	req, err := http.NewRequest("GET", "/", http.NoBody)
+	assert.Nil(t, err, "")
+	req.Header.Add("date", time.Now().UTC().Format(http.TimeFormat))
+	err = signer.SignRequest(privkey, "default", req, body)
+	assert.Nil(t, err, "")
+
+	err = signature.HTTPSignatureHandler(req, ts)
+	assert.Nil(t, err, "")
+}

+ 21 - 0
pkg/middleware/tracing.go

@@ -0,0 +1,21 @@
+package middleware
+
+import (
+	"context"
+	"net/http"
+)
+
+// Tracing is a middleware to trace HTTP request
+func Tracing(nextRequestID func() string) Middleware {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			requestID := r.Header.Get("X-Request-Id")
+			if requestID == "" {
+				requestID = nextRequestID()
+			}
+			ctx := context.WithValue(r.Context(), requestIDKey, requestID)
+			w.Header().Set("X-Request-Id", requestID)
+			next.ServeHTTP(w, r.WithContext(ctx))
+		})
+	}
+}

+ 19 - 0
pkg/middleware/types.go

@@ -0,0 +1,19 @@
+package middleware
+
+import "net/http"
+
+// Middleware function definition
+type Middleware func(inner http.Handler) http.Handler
+
+// Middlewares list
+type Middlewares []Middleware
+
+// UseBefore insert a middleware at the beginning of the middleware chain
+func (ms Middlewares) UseBefore(m Middleware) Middlewares {
+	return append([]Middleware{m}, ms...)
+}
+
+// UseAfter add a middleware at the end of the middleware chain
+func (ms Middlewares) UseAfter(m Middleware) Middlewares {
+	return append(ms, m)
+}

+ 27 - 0
pkg/middleware/xff.go

@@ -0,0 +1,27 @@
+package middleware
+
+import (
+	"net"
+	"net/http"
+)
+
+const xForwardedFor = "X-Forwarded-For"
+
+func getIP(req *http.Request) string {
+	ip, _, err := net.SplitHostPort(req.RemoteAddr)
+	if err != nil {
+		return req.RemoteAddr
+	}
+	return ip
+}
+
+// XFF is a middleware to identifying the originating IP address using X-Forwarded-For header
+func XFF(inner http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		xff := r.Header.Get(xForwardedFor)
+		if xff == "" {
+			r.Header.Set(xForwardedFor, getIP(r))
+		}
+		inner.ServeHTTP(w, r)
+	})
+}

+ 8 - 0
pkg/notification/all/all.go

@@ -0,0 +1,8 @@
+package all
+
+import (
+	// activate HTTP notifier
+	_ "github.com/ncarlier/webhookd/pkg/notification/http"
+	// activate SMTP notifier
+	_ "github.com/ncarlier/webhookd/pkg/notification/smtp"
+)

+ 75 - 0
pkg/notification/http/http_notifier.go

@@ -0,0 +1,75 @@
+package http
+
+import (
+	"bytes"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+
+	"github.com/ncarlier/webhookd/pkg/helper"
+	"github.com/ncarlier/webhookd/pkg/notification"
+)
+
+type notifPayload struct {
+	ID    string `json:"id"`
+	Name  string `json:"name"`
+	Text  string `json:"text"`
+	Error error  `json:"error,omitempty"`
+}
+
+// httpNotifier is able to send a notification to a HTTP endpoint.
+type httpNotifier struct {
+	URL          *url.URL
+	PrefixFilter string
+}
+
+func newHTTPNotifier(uri *url.URL) (notification.Notifier, error) {
+	slog.Info("using HTTP notification system", "uri", uri.Redacted())
+	return &httpNotifier{
+		URL:          uri,
+		PrefixFilter: helper.GetValueOrAlt(uri.Query(), "prefix", "notify:"),
+	}, nil
+}
+
+// Notify send a notification to a HTTP endpoint.
+func (n *httpNotifier) Notify(result notification.HookResult) error {
+	payload := result.Logs(n.PrefixFilter)
+	if strings.TrimSpace(payload) == "" {
+		// Nothing to notify, abort
+		return nil
+	}
+
+	notif := &notifPayload{
+		ID:    strconv.FormatUint(result.ID(), 10),
+		Name:  result.Name(),
+		Text:  payload,
+		Error: result.Err(),
+	}
+	notifJSON, err := json.Marshal(notif)
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest("POST", n.URL.String(), bytes.NewBuffer(notifJSON))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json")
+
+	client := &http.Client{}
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	resp.Body.Close()
+	slog.Info("notification sent", "hook", result.Name(), "id", result.ID(), "to", n.URL.Opaque)
+	return nil
+}
+
+func init() {
+	notification.Register("http", newHTTPNotifier)
+	notification.Register("https", newHTTPNotifier)
+}

+ 28 - 0
pkg/notification/notifier.go

@@ -0,0 +1,28 @@
+package notification
+
+import (
+	"log/slog"
+)
+
+// Notifier is able to send a notification.
+type Notifier interface {
+	Notify(result HookResult) error
+}
+
+var notifier Notifier
+
+// Notify is the global method to notify hook result
+func Notify(result HookResult) {
+	if notifier == nil {
+		return
+	}
+	if err := notifier.Notify(result); err != nil {
+		slog.Error("unable to send notification", "webhook", result.Name(), "id", result.ID(), "err", err)
+	}
+}
+
+// Init creates the notifier singleton regarding the URI.
+func Init(uri string) (err error) {
+	notifier, err = NewNotifier(uri)
+	return err
+}

+ 33 - 0
pkg/notification/registry.go

@@ -0,0 +1,33 @@
+package notification
+
+import (
+	"fmt"
+	"net/url"
+)
+
+// NotifierCreator function for create a notifier
+type NotifierCreator func(uri *url.URL) (Notifier, error)
+
+// Registry of all Notifiers
+var registry = map[string]NotifierCreator{}
+
+// Register a Notifier to the registry
+func Register(scheme string, creator NotifierCreator) {
+	registry[scheme] = creator
+}
+
+// NewNotifier create new Notifier
+func NewNotifier(uri string) (Notifier, error) {
+	if uri == "" {
+		return nil, nil
+	}
+	u, err := url.Parse(uri)
+	if err != nil {
+		return nil, fmt.Errorf("invalid notification URL: %s", uri)
+	}
+	creator, ok := registry[u.Scheme]
+	if !ok {
+		return nil, fmt.Errorf("unsupported notification scheme: %s", u.Scheme)
+	}
+	return creator(u)
+}

+ 146 - 0
pkg/notification/smtp/smtp_notifier.go

@@ -0,0 +1,146 @@
+package smtp
+
+import (
+	"crypto/tls"
+	"fmt"
+	"log/slog"
+	"net"
+	"net/smtp"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/ncarlier/webhookd/pkg/helper"
+	"github.com/ncarlier/webhookd/pkg/notification"
+)
+
+// smtpNotifier is able to send notification to a email destination.
+type smtpNotifier struct {
+	Host         string
+	Username     string
+	Password     string
+	Conn         string
+	From         string
+	To           string
+	Subject      string
+	PrefixFilter string
+}
+
+func newSMTPNotifier(uri *url.URL) (notification.Notifier, error) {
+	slog.Info("using SMTP notification system", "uri", uri.Redacted())
+	q := uri.Query()
+	return &smtpNotifier{
+		Host:         helper.GetValueOrAlt(q, "smtp", "localhost:25"),
+		Username:     helper.GetValueOrAlt(q, "username", ""),
+		Password:     helper.GetValueOrAlt(q, "password", ""),
+		Conn:         helper.GetValueOrAlt(q, "conn", "plain"),
+		From:         helper.GetValueOrAlt(q, "from", "noreply@nunux.org"),
+		To:           uri.Opaque,
+		Subject:      helper.GetValueOrAlt(uri.Query(), "subject", "[whd-notification] {name}#{id} {status}"),
+		PrefixFilter: helper.GetValueOrAlt(uri.Query(), "prefix", "notify:"),
+	}, nil
+}
+
+func (n *smtpNotifier) buildEmailPayload(result notification.HookResult) string {
+	// Get email body
+	body := result.Logs(n.PrefixFilter)
+	if strings.TrimSpace(body) == "" {
+		return ""
+	}
+
+	// Build email subject
+	subject := buildSubject(n.Subject, result)
+
+	// Build email headers
+	headers := make(map[string]string)
+	headers["From"] = n.From
+	headers["To"] = n.To
+	headers["Subject"] = subject
+
+	// Build email payload
+	payload := ""
+	for k, v := range headers {
+		payload += fmt.Sprintf("%s: %s\r\n", k, v)
+	}
+	payload += "\r\n" + body
+	return payload
+}
+
+// Notify send a notification to a email destination.
+func (n *smtpNotifier) Notify(result notification.HookResult) error {
+	hostname, _, _ := net.SplitHostPort(n.Host)
+	payload := n.buildEmailPayload(result)
+	if payload == "" {
+		// Nothing to notify, abort
+		return nil
+	}
+
+	// Dial connection
+	conn, err := net.DialTimeout("tcp", n.Host, 5*time.Second)
+	if err != nil {
+		return err
+	}
+	// Connect to SMTP server
+	client, err := smtp.NewClient(conn, hostname)
+	if err != nil {
+		return err
+	}
+
+	if n.Conn == "tls" || n.Conn == "tls-insecure" {
+		// TLS config
+		tlsConfig := &tls.Config{
+			InsecureSkipVerify: n.Conn == "tls-insecure",
+			ServerName:         hostname,
+		}
+		if err := client.StartTLS(tlsConfig); err != nil {
+			return err
+		}
+	}
+
+	// Set auth if needed
+	if n.Username != "" {
+		if err := client.Auth(smtp.PlainAuth("", n.Username, n.Password, hostname)); err != nil {
+			return err
+		}
+	}
+
+	// Set the sender and recipient first
+	if err := client.Mail(n.From); err != nil {
+		return err
+	}
+	if err := client.Rcpt(n.To); err != nil {
+		return err
+	}
+
+	// Send the email body.
+	wc, err := client.Data()
+	if err != nil {
+		return err
+	}
+
+	_, err = wc.Write([]byte(payload))
+	if err != nil {
+		return err
+	}
+	err = wc.Close()
+	if err != nil {
+		return err
+	}
+
+	slog.Info("notification sent", "hook", result.Name(), "id", result.ID(), "to", n.To)
+
+	// Send the QUIT command and close the connection.
+	return client.Quit()
+}
+
+func buildSubject(template string, result notification.HookResult) string {
+	subject := strings.ReplaceAll(template, "{name}", result.Name())
+	subject = strings.ReplaceAll(subject, "{id}", strconv.FormatUint(uint64(result.ID()), 10))
+	subject = strings.ReplaceAll(subject, "{status}", result.StatusLabel())
+	return subject
+}
+
+func init() {
+	notification.Register("mailto", newSMTPNotifier)
+}

+ 10 - 0
pkg/notification/types.go

@@ -0,0 +1,10 @@
+package notification
+
+// HookResult is the result of a hook execution
+type HookResult interface {
+	ID() uint64
+	Name() string
+	Logs(filter string) string
+	StatusLabel() string
+	Err() error
+}

+ 77 - 0
pkg/server/server.go

@@ -0,0 +1,77 @@
+package server
+
+import (
+	"context"
+	"log/slog"
+	"net/http"
+	"os"
+	"os/user"
+	"path/filepath"
+
+	"github.com/ncarlier/webhookd/pkg/api"
+	"github.com/ncarlier/webhookd/pkg/config"
+
+	"golang.org/x/crypto/acme/autocert"
+)
+
+func cacheDir() (dir string) {
+	if u, _ := user.Current(); u != nil {
+		dir = filepath.Join(os.TempDir(), "webhookd-acme-cache-"+u.Username)
+		if err := os.MkdirAll(dir, 0o700); err == nil {
+			return dir
+		}
+	}
+	return ""
+}
+
+// Server is a HTTP server wrapper used to manage TLS
+type Server struct {
+	self     *http.Server
+	tls      bool
+	certFile string
+	keyFile  string
+}
+
+// ListenAndServe start HTTP(s) server
+func (s *Server) ListenAndServe() error {
+	if s.tls {
+		return s.self.ListenAndServeTLS(s.certFile, s.keyFile)
+	}
+	return s.self.ListenAndServe()
+}
+
+// Shutdown stop HTTP(s) server
+func (s *Server) Shutdown(ctx context.Context) error {
+	s.self.SetKeepAlivesEnabled(false)
+	return s.self.Shutdown(ctx)
+}
+
+// NewServer create new HTTP(s) server
+func NewServer(cfg *config.Config) *Server {
+	logger := slog.NewLogLogger(slog.Default().Handler(), slog.LevelError)
+	server := &Server{
+		tls: cfg.TLS.Enabled,
+		self: &http.Server{
+			Addr:     cfg.ListenAddr,
+			Handler:  api.NewRouter(cfg),
+			ErrorLog: logger,
+		},
+	}
+	if server.tls {
+		// HTTPs server
+		if cfg.TLS.Domain == "" {
+			server.certFile = cfg.TLS.CertFile
+			server.keyFile = cfg.TLS.KeyFile
+		} else {
+			m := &autocert.Manager{
+				Cache:      autocert.DirCache(cacheDir()),
+				Prompt:     autocert.AcceptTOS,
+				HostPolicy: autocert.HostWhitelist(cfg.TLS.Domain),
+			}
+			server.self.TLSConfig = m.TLSConfig()
+			server.certFile = ""
+			server.keyFile = ""
+		}
+	}
+	return server
+}

+ 31 - 0
pkg/truststore/p12_truststore.go

@@ -0,0 +1,31 @@
+package truststore
+
+import (
+	"crypto"
+	"log/slog"
+	"os"
+
+	"golang.org/x/crypto/pkcs12"
+)
+
+func newP12TrustStore(filename string) (TrustStore, error) {
+	data, err := os.ReadFile(filename)
+	if err != nil {
+		return nil, err
+	}
+
+	_, cert, err := pkcs12.Decode(data, "test")
+	if err != nil {
+		return nil, err
+	}
+
+	result := &InMemoryTrustStore{
+		Keys: make(map[string]crypto.PublicKey),
+	}
+
+	keyID := string(cert.Subject.CommonName)
+	result.Keys[keyID] = cert.PublicKey
+	slog.Debug("certificate loaded into the trustore", "id", keyID)
+
+	return result, nil
+}

+ 56 - 0
pkg/truststore/pem_truststore.go

@@ -0,0 +1,56 @@
+package truststore
+
+import (
+	"crypto"
+	"crypto/x509"
+	"encoding/pem"
+	"fmt"
+	"log/slog"
+	"os"
+)
+
+func newPEMTrustStore(filename string) (TrustStore, error) {
+	raw, err := os.ReadFile(filename)
+	if err != nil {
+		return nil, err
+	}
+
+	result := &InMemoryTrustStore{
+		Keys: make(map[string]crypto.PublicKey),
+	}
+	for {
+		block, rest := pem.Decode(raw)
+		if block == nil {
+			break
+		}
+		switch block.Type {
+		case "PUBLIC KEY":
+			keyID, ok := block.Headers["key_id"]
+			if !ok {
+				keyID = "default"
+			}
+
+			key, err := x509.ParsePKIXPublicKey(block.Bytes)
+			if err != nil {
+				return nil, err
+			}
+
+			result.Keys[keyID] = key
+			slog.Debug("public key loaded into the trustore", "id", keyID)
+		case "CERTIFICATE":
+			cert, err := x509.ParseCertificate(block.Bytes)
+			if err != nil {
+				return nil, err
+			}
+			keyID := string(cert.Subject.CommonName)
+			result.Keys[keyID] = cert.PublicKey
+			slog.Debug("certificate loaded into the trustore", "id", keyID)
+		}
+		raw = rest
+	}
+
+	if len(result.Keys) == 0 {
+		return nil, fmt.Errorf("no RSA public key found: %s", filename)
+	}
+	return result, nil
+}

+ 21 - 0
pkg/truststore/test/p12_truststore_test.go

@@ -0,0 +1,21 @@
+package test
+
+import (
+	"crypto/rsa"
+	"testing"
+
+	"github.com/ncarlier/webhookd/pkg/assert"
+	"github.com/ncarlier/webhookd/pkg/truststore"
+)
+
+func TestTrustStoreWithP12(t *testing.T) {
+	t.Skip()
+
+	ts, err := truststore.New("test.p12")
+	assert.Nil(t, err, "")
+	assert.NotNil(t, ts, "")
+	pubkey := ts.GetPublicKey("test.localnet")
+	assert.NotNil(t, pubkey, "")
+	_, ok := pubkey.(*rsa.PublicKey)
+	assert.True(t, ok, "")
+}

+ 55 - 0
pkg/truststore/test/pem_truststore_test.go

@@ -0,0 +1,55 @@
+package test
+
+import (
+	"crypto/rsa"
+	"testing"
+
+	"github.com/ncarlier/webhookd/pkg/assert"
+	"github.com/ncarlier/webhookd/pkg/truststore"
+)
+
+func TestTrustStoreWithNoKeyID(t *testing.T) {
+	ts, err := truststore.New("test-key-01.pem")
+	assert.Nil(t, err, "")
+	assert.NotNil(t, ts, "")
+	pubkey := ts.GetPublicKey("test")
+	assert.True(t, pubkey == nil, "")
+	pubkey = ts.GetPublicKey("default")
+	assert.NotNil(t, pubkey, "")
+	_, ok := pubkey.(*rsa.PublicKey)
+	assert.True(t, ok, "")
+}
+
+func TestTrustStoreWithKeyID(t *testing.T) {
+	ts, err := truststore.New("test-key-02.pem")
+	assert.Nil(t, err, "")
+	assert.NotNil(t, ts, "")
+	pubkey := ts.GetPublicKey("test")
+	assert.NotNil(t, pubkey, "")
+	_, ok := pubkey.(*rsa.PublicKey)
+	assert.True(t, ok, "")
+}
+
+func TestTrustStoreWithCertificate(t *testing.T) {
+	ts, err := truststore.New("test-cert.pem")
+	assert.Nil(t, err, "")
+	assert.NotNil(t, ts, "")
+	pubkey := ts.GetPublicKey("test.localnet")
+	assert.NotNil(t, pubkey, "")
+	_, ok := pubkey.(*rsa.PublicKey)
+	assert.True(t, ok, "")
+}
+
+func TestTrustStoreWithMultipleEntries(t *testing.T) {
+	ts, err := truststore.New("test-multi.pem")
+	assert.Nil(t, err, "")
+	assert.NotNil(t, ts, "")
+	pubkey := ts.GetPublicKey("test.localnet")
+	assert.NotNil(t, pubkey, "")
+	_, ok := pubkey.(*rsa.PublicKey)
+	assert.True(t, ok, "")
+	pubkey = ts.GetPublicKey("foo")
+	assert.NotNil(t, pubkey, "")
+	_, ok = pubkey.(*rsa.PublicKey)
+	assert.True(t, ok, "")
+}

BIN
pkg/truststore/test/test.p12


+ 45 - 0
pkg/truststore/truststore.go

@@ -0,0 +1,45 @@
+package truststore
+
+import (
+	"crypto"
+	"fmt"
+	"log/slog"
+	"path/filepath"
+)
+
+// TrustStore is a generic interface to retrieve a public key
+type TrustStore interface {
+	GetPublicKey(keyID string) crypto.PublicKey
+}
+
+// InMemoryTrustStore is a in memory storage for public keys
+type InMemoryTrustStore struct {
+	Keys map[string]crypto.PublicKey
+}
+
+// GetPublicKey returns the public key with this key ID
+func (ts *InMemoryTrustStore) GetPublicKey(keyID string) crypto.PublicKey {
+	if key, ok := ts.Keys[keyID]; ok {
+		return key
+	}
+	return nil
+}
+
+// New creates new Trust Store from URI
+func New(filename string) (store TrustStore, err error) {
+	if filename == "" {
+		return nil, nil
+	}
+
+	slog.Debug("loading truststore...", "filname", filename)
+	switch filepath.Ext(filename) {
+	case ".pem":
+		store, err = newPEMTrustStore(filename)
+	case ".p12":
+		store, err = newP12TrustStore(filename)
+	default:
+		err = fmt.Errorf("unsupported truststore file format: %s", filename)
+	}
+
+	return
+}

+ 55 - 0
pkg/version/version.go

@@ -0,0 +1,55 @@
+package version
+
+import (
+	"flag"
+	"fmt"
+	"runtime/debug"
+	"time"
+)
+
+// Version of the app
+var Version = "snapshot"
+
+// GitCommit is the GIT commit revision
+var GitCommit = "n/a"
+
+// Built is the built date
+var Built = "n/a"
+
+// ShowVersion is the flag used to print version
+var ShowVersion = flag.Bool("version", false, "Print version")
+
+// Print version to stdout
+func Print() {
+	fmt.Printf(`Version:    %s
+Git commit: %s
+Built:      %s
+
+Copyright (C) 2020 Nicolas Carlier
+This is free software: you are free to change and redistribute it.
+There is NO WARRANTY, to the extent permitted by law.
+`, Version, GitCommit, Built)
+}
+
+func init() {
+	if GitCommit != "snapshot" {
+		return
+	}
+	info, ok := debug.ReadBuildInfo()
+	if !ok {
+		return
+	}
+	Version = info.Main.Version
+	for _, kv := range info.Settings {
+		if kv.Value == "" {
+			continue
+		}
+		switch kv.Key {
+		case "vcs.revision":
+			GitCommit = kv.Value[:7]
+		case "vcs.time":
+			lastCommit, _ := time.Parse(time.RFC3339, kv.Value)
+			Built = lastCommit.Format(time.RFC1123)
+		}
+	}
+}

+ 35 - 0
pkg/worker/dispatcher.go

@@ -0,0 +1,35 @@
+package worker
+
+import (
+	"log/slog"
+)
+
+// WorkerQueue is the global queue of Workers
+var WorkerQueue chan chan Work
+
+// WorkQueue is the global queue of work to dispatch
+var WorkQueue = make(chan Work, 100)
+
+// StartDispatcher is charged to start n workers.
+func StartDispatcher(nworkers int) {
+	// First, initialize the channel we are going to but the workers' work channels into.
+	WorkerQueue = make(chan chan Work, nworkers)
+
+	// Now, create all of our workers.
+	for i := 0; i < nworkers; i++ {
+		slog.Debug("starting worker...", "worker", i+1)
+		worker := NewWorker(i+1, WorkerQueue)
+		worker.Start()
+	}
+
+	go func() {
+		for {
+			work := <-WorkQueue
+			go func() {
+				worker := <-WorkerQueue
+				slog.Debug("dispatching hook request", "hook", work.Name(), "id", work.ID())
+				worker <- work
+			}()
+		}
+	}()
+}

+ 23 - 0
pkg/worker/types.go

@@ -0,0 +1,23 @@
+package worker
+
+// ChanWriter is a simple writer to a channel of byte.
+type ChanWriter struct {
+	ByteChan chan []byte
+}
+
+func (c *ChanWriter) Write(p []byte) (int, error) {
+	c.ByteChan <- p
+	return len(p), nil
+}
+
+// Work is a dispatched work given to a worker
+type Work interface {
+	ID() uint64
+	Name() string
+	Run() error
+	Close()
+	SendMessage(message string)
+	Logs(filter string) string
+	StatusLabel() string
+	Err() error
+}

+ 68 - 0
pkg/worker/worker.go

@@ -0,0 +1,68 @@
+package worker
+
+import (
+	"fmt"
+	"log/slog"
+
+	"github.com/ncarlier/webhookd/pkg/metric"
+	"github.com/ncarlier/webhookd/pkg/notification"
+)
+
+// NewWorker creates, and returns a new Worker object.
+func NewWorker(id int, workerQueue chan chan Work) Worker {
+	// Create, and return the worker.
+	worker := Worker{
+		ID:          id,
+		Work:        make(chan Work),
+		WorkerQueue: workerQueue,
+		QuitChan:    make(chan bool),
+	}
+
+	return worker
+}
+
+// Worker is a go routine in charge of executing a work.
+type Worker struct {
+	ID          int
+	Work        chan Work
+	WorkerQueue chan chan Work
+	QuitChan    chan bool
+}
+
+// Start is the function to starts the worker by starting a goroutine.
+// That is an infinite "for-select" loop.
+func (w Worker) Start() {
+	go func() {
+		for {
+			// Add ourselves into the worker queue.
+			w.WorkerQueue <- w.Work
+
+			select {
+			case work := <-w.Work:
+				// Receive a work request.
+				slog.Debug("hook execution request received", "worker", w.ID, "hook", work.Name(), "id", work.ID())
+				metric.Requests.Add(1)
+				err := work.Run()
+				if err != nil {
+					metric.RequestsFailed.Add(1)
+					work.SendMessage(fmt.Sprintf("error: %s", err.Error()))
+				}
+				// Send notification
+				go notification.Notify(work)
+
+				work.Close()
+			case <-w.QuitChan:
+				slog.Debug("stopping worker...", "worker", w.ID)
+				return
+			}
+		}
+	}()
+}
+
+// Stop tells the worker to stop listening for work requests.
+// Note that the worker will only stop *after* it has finished its work.
+func (w Worker) Stop() {
+	go func() {
+		w.QuitChan <- true
+	}()
+}

+ 10 - 0
scripts/async.sh

@@ -0,0 +1,10 @@
+#!/bin/bash
+
+
+echo "Starting background job..."
+
+nohup ./scripts/long.sh >/tmp/long.log 2>&1  &
+
+echo "Background job started."
+
+

+ 15 - 0
scripts/echo.sh

@@ -0,0 +1,15 @@
+#!/bin/bash
+
+# Usage: http POST :8080/echo msg==hello foo=bar
+
+echo "This is a simple echo hook."
+
+echo "Hook information: name=$hook_name, id=$hook_id, method=$hook_method"
+
+echo "Command result: hostname=`hostname`"
+
+echo "Header variable: User-Agent=$user_agent"
+
+echo "Query parameter: msg=$msg"
+
+echo "Body payload: $1"

+ 19 - 0
scripts/examples/echo.js

@@ -0,0 +1,19 @@
+#!/usr/bin/env node
+
+const os = require('os')
+
+// Usage: http POST :8080/examples/echo.js msg==hello foo=bar
+
+console.log("This is a simple echo hook using NodeJS.")
+
+const { hook_name, hook_id , user_agent, msg} = process.env
+
+console.log(`Hook information: name=${hook_name}, id=${hook_id}`)
+
+console.log("Hostname=", os.hostname())
+
+console.log(`User-Agent=${user_agent}`)
+
+console.log(`msg=${msg}`)
+
+console.log("body=", process.argv)

+ 37 - 0
scripts/examples/github.sh

@@ -0,0 +1,37 @@
+#!/bin/sh
+
+# Functions
+die() { echo "error: $@" 1>&2 ; exit 1; }
+confDie() { echo "error: $@ Check the server configuration!" 1>&2 ; exit 2; }
+debug() {
+  [ "$debug" = "true" ] && echo "debug: $@"
+}
+
+# Validate global configuration
+[ -z "$GITHUB_SECRET" ] && confDie "GITHUB_SECRET not set."
+
+# Validate Github hook
+signature=$(echo -n "$1" | openssl sha1 -hmac "$GITHUB_SECRET" | sed -e 's/^.* //')
+[ "sha1=$signature" != "$x_hub_signature" ] && die "bad hook signature: expecting $x_hub_signature and got $signature"
+
+# Validate parameters
+payload=$1
+[ -z "$payload" ] && die "missing request payload"
+payload_type=$(echo $payload | jq type -r)
+[ $? != 0 ] && die "bad body format: expecting JSON"
+[ ! $payload_type = "object" ] && die "bad body format: expecting JSON object but having $payload_type"
+
+debug "received payload: $payload"
+
+# Extract values
+action=$(echo $payload | jq .action -r)
+[ $? != 0 -o "$action" = "null" ] && die "unable to extract 'action' from JSON payload"
+
+# Do something with the payload:
+# Here create a simple notification when an issue has been published
+if [ "$action" = "opened" ]
+then
+  issue_url=$(echo $payload | jq .issue.url -r)
+  sender=$(echo $payload | jq .sender.login -r)
+  echo "notify: New issue from $sender: $issue_url"
+fi

+ 38 - 0
scripts/examples/gitlab.sh

@@ -0,0 +1,38 @@
+#!/bin/sh
+
+# Functions
+die() { echo "error: $@" 1>&2 ; exit 1; }
+confDie() { echo "error: $@ Check the server configuration!" 1>&2 ; exit 2; }
+debug() {
+  [ "$debug" = "true" ] && echo "debug: $@"
+}
+
+# Validate global configuration
+[ -z "$GITLAB_TOKEN" ] && confDie "GITLAB_TOKEN not set."
+
+# Validate Gitlab hook
+[ "$x_gitlab_token" != "$GITLAB_TOKEN" ] && die "bad hook token"
+
+# Validate parameters
+payload=$1
+payload="$(echo "$payload"|tr -d '\n')"
+[ -z "$payload" ] && die "missing request payload"
+payload_type=$(echo $payload | jq type -r)
+[ $? != 0 ] && die "bad body format: expecting JSON"
+[ ! $payload_type = "object" ] && die "bad body format: expecting JSON object but having $payload_type"
+
+debug "received payload: $payload"
+
+# Extract values
+object_kind=$(echo $payload | jq .object_kind -r)
+[ $? != 0 -o "$object_kind" = "null" ] && die "unable to extract 'object_kind' from JSON payload"
+
+# Do something with the payload:
+# Here create a simple notification when a push has occured
+if [ "$object_kind" = "push" ]
+then
+  username=$(echo $payload | jq .user_name -r)
+  git_ssh_url=$(echo $payload | jq .project.git_ssh_url -r)
+  total_commits_count=$(echo $payload | jq .total_commits_count -r)
+  echo "notify: $username push $total_commits_count commit(s) on $git_ssh_url"
+fi

+ 12 - 0
scripts/long.sh

@@ -0,0 +1,12 @@
+#!/bin/bash
+
+echo "Running long script..."
+
+for i in {1..20}; do
+  sleep 1
+  echo "running ${i} ..."
+done
+
+echo "Long script end"
+
+exit 0

+ 16 - 0
tooling/bench/simple.js

@@ -0,0 +1,16 @@
+import http from 'k6/http';
+import { check, sleep } from 'k6';
+
+export const options = {
+  stages: [
+    { duration: '30s', target: 20 },
+    { duration: '1m', target: 10 },
+    { duration: '20s', target: 0 },
+  ],
+};
+
+export default function () {
+  const res = http.get('http://localhost:8080/echo');
+  check(res, { 'status was 200': (r) => r.status == 200 });
+  sleep(1);
+}

+ 38 - 0
tooling/html/console.html

@@ -0,0 +1,38 @@
+ <!DOCTYPE html>
+<html>
+<head>
+    <title>Basic Webhookd UI</title>
+    <meta charset="UTF-8">
+</head>
+<body>
+    <form onsubmit="return sendRequest(this)">
+        <input name="action" type="text" value="echo" required />
+        <button type="submit">GET</button>
+    </form>
+    <pre id="result">
+        <!--Server response will be inserted here-->
+    </pre>
+    
+    <script>
+        /**
+          * @param {HTMLFormElement} form - Form with action.
+          */
+        function sendRequest(form) {
+            const action = form.elements.namedItem("action").value;
+            const source = new EventSource(`http://localhost:8080/${action}`);
+            source.onopen = () => {
+                console.log('connected');
+            };
+            source.onmessage = (event) => {
+                console.log(event.data);
+                document.getElementById("result").innerHTML += event.data + "<br>";
+            };
+            source.onerror = event => {
+                console.log(event);
+                source.close()
+            };
+            return false;
+        }
+</script>
+</body>
+</html> 

+ 40 - 0
tooling/httpsig/Makefile

@@ -0,0 +1,40 @@
+.SILENT :
+
+export GO111MODULE=on
+
+# App name
+APPNAME=httpsig
+
+# Go configuration
+GOOS?=linux
+GOARCH?=amd64
+
+# Add exe extension if windows target
+is_windows:=$(filter windows,$(GOOS))
+EXT:=$(if $(is_windows),".exe","")
+
+# Archive name
+ARCHIVE=$(APPNAME)-$(GOOS)-$(GOARCH).tgz
+
+# Executable name
+EXECUTABLE=$(APPNAME)$(EXT)
+
+all: build
+
+clean:
+	-rm -rf release
+.PHONY: clean
+
+## Build executable
+build:
+	-mkdir -p release
+	echo "Building: $(EXECUTABLE) $(VERSION) for $(GOOS)-$(GOARCH) ..."
+	GOOS=$(GOOS) GOARCH=$(GOARCH) go build $(LDFLAGS) -o release/$(EXECUTABLE)
+.PHONY: build
+
+release/$(EXECUTABLE): build
+
+key:
+	openssl genrsa -out key.pem 2048
+	openssl rsa -in key.pem -outform PEM -pubout -out key-pub.pem
+.PHONY: key

+ 33 - 0
tooling/httpsig/README.md

@@ -0,0 +1,33 @@
+# httpsig
+
+A simple HTTP client with HTTP signature support.
+
+# Usage
+
+- Generate an RSA key: `make key`
+
+- Add `key_id` header to public key:
+  
+```pem
+-----BEGIN PUBLIC KEY-----
+key_id: my-key
+
+MIIEowIBAAKCAQEAwdCB5DZD0cFeJYUu1W3IlNN9y+NZC/Jqktdkn8/WHlXec07n
+...
+-----END PUBLIC KEY-----
+```
+
+- Start Webhookd with HTTP signature support:
+
+```bash
+$ webhookd --trust-store-file ./key-pub.pem
+```
+
+- Make HTTP signed request:
+
+```bash
+$ ./release/httpsig \
+  --key-id my-key \
+  --key-file ./key.pem \
+  http://localhost:8080/echo`
+```

+ 15 - 0
tooling/httpsig/go.mod

@@ -0,0 +1,15 @@
+module github.com/ncarlier/webhookd/tooling/httpsig
+
+go 1.19
+
+require (
+	github.com/go-fed/httpsig v1.1.0
+	github.com/ncarlier/webhookd v1.0.0-00010101000000-000000000000
+)
+
+require (
+	golang.org/x/crypto v0.7.0 // indirect
+	golang.org/x/sys v0.6.0 // indirect
+)
+
+replace github.com/ncarlier/webhookd => ../..

+ 13 - 0
tooling/httpsig/go.sum

@@ -0,0 +1,13 @@
+github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
+github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
+golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

+ 118 - 0
tooling/httpsig/main.go

@@ -0,0 +1,118 @@
+package main
+
+import (
+	"bufio"
+	"bytes"
+	"crypto/x509"
+	"encoding/pem"
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"net/http/httputil"
+	"net/url"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/go-fed/httpsig"
+	configflag "github.com/ncarlier/webhookd/pkg/config/flag"
+)
+
+type config struct {
+	KeyID   string `flag:"key-id" desc:"Signature key ID"`
+	KeyFile string `flag:"key-file" desc:"Private key file (PEM format)" default:"./key.pem"`
+	JSON    string `flag:"json" desc:"JSON payload"`
+}
+
+func main() {
+	conf := &config{}
+	configflag.Bind(conf, "HTTP_SIG")
+
+	flag.Parse()
+
+	if conf.KeyID == "" {
+		log.Fatal("missing key ID")
+	}
+
+	args := flag.Args()
+	if len(args) <= 0 {
+		log.Fatal("missing target URL")
+	}
+	targetURL := args[0]
+	if _, err := url.Parse(targetURL); err != nil {
+		log.Fatal("invalid target URL")
+	}
+
+	keyBytes, err := os.ReadFile(conf.KeyFile)
+	if err != nil {
+		log.Fatal(err.Error())
+	}
+
+	pemBlock, _ := pem.Decode(keyBytes)
+	if pemBlock == nil {
+		log.Fatal("invalid PEM format")
+	}
+
+	privateKey, err := x509.ParsePKCS1PrivateKey(pemBlock.Bytes)
+	if err != nil {
+		log.Fatal(err.Error())
+	}
+
+	var payload io.Reader
+	var jsonBytes []byte
+	if conf.JSON != "" {
+		var err error
+		jsonBytes, err = os.ReadFile(conf.JSON)
+		if err != nil {
+			log.Fatal(err.Error())
+		}
+		payload = bytes.NewReader(jsonBytes)
+	}
+
+	prefs := []httpsig.Algorithm{httpsig.RSA_SHA256}
+	digestAlgorithm := httpsig.DigestSha256
+	headers := []string{httpsig.RequestTarget, "date"}
+	signer, _, err := httpsig.NewSigner(prefs, digestAlgorithm, headers, httpsig.Signature, 0)
+	if err != nil {
+		log.Fatal(err.Error())
+	}
+
+	req, err := http.NewRequest("POST", targetURL, payload)
+	if err != nil {
+		log.Fatal(err.Error())
+	}
+	if payload != nil {
+		req.Header.Add("content-type", "application/json")
+	}
+	req.Header.Add("date", time.Now().UTC().Format(http.TimeFormat))
+
+	if err = signer.SignRequest(privateKey, conf.KeyID, req, jsonBytes); err != nil {
+		log.Fatal(err.Error())
+	}
+
+	dump, err := httputil.DumpRequest(req, true)
+	if err != nil {
+		log.Fatal(err.Error())
+	}
+	scanner := bufio.NewScanner(strings.NewReader(string(dump)))
+	for scanner.Scan() {
+		fmt.Println(">", scanner.Text())
+	}
+
+	client := &http.Client{Timeout: 10 * time.Second}
+	res, err := client.Do(req)
+	if err != nil {
+		log.Fatal(err.Error())
+	}
+
+	dump, err = httputil.DumpResponse(res, true)
+	if err != nil {
+		log.Fatal(err.Error())
+	}
+	scanner = bufio.NewScanner(strings.NewReader(string(dump)))
+	for scanner.Scan() {
+		fmt.Println("<", scanner.Text())
+	}
+}

+ 110 - 0
webhookd.svg

@@ -0,0 +1,110 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   viewBox="0 0 475 210"
+   version="1.1"
+   id="svg4585"
+   sodipodi:docname="webhookd.svg"
+   inkscape:version="0.92.1 r15371">
+  <metadata
+     id="metadata4589">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title>integration-tile</dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="1920"
+     inkscape:window-height="922"
+     id="namedview4587"
+     showgrid="false"
+     inkscape:zoom="1.4747368"
+     inkscape:cx="320.52924"
+     inkscape:cy="152.06842"
+     inkscape:window-x="0"
+     inkscape:window-y="31"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg4585" />
+  <defs
+     id="defs4559">
+    <style
+       id="style4557">.a{fill:#c73a63;}.b{fill:#4b4b4b;}.c{fill:#4a4a4a;}</style>
+  </defs>
+  <title
+     id="title4561">integration-tile</title>
+  <g
+     aria-label="d"
+     style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:42.66666794px;line-height:125%;font-family:Sans;-inkscape-font-specification:'Sans, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+     id="text4593">
+    <path
+       d="m 385.52907,99.918309 v -12.5 h 7.5 v 32.416671 h -7.5 v -3.375 q -1.54166,2.0625 -3.39583,3.02083 -1.85417,0.95833 -4.29167,0.95833 -4.3125,0 -7.08333,-3.41666 -2.77083,-3.4375 -2.77083,-8.83334 0,-5.39583 2.77083,-8.812498 2.77083,-3.4375 7.08333,-3.4375 2.41667,0 4.27084,0.979167 1.875,0.958333 3.41666,3 z m -4.91666,15.104171 q 2.39583,0 3.64583,-1.75 1.27083,-1.75 1.27083,-5.08334 0,-3.33333 -1.27083,-5.08333 -1.25,-1.75 -3.64583,-1.75 -2.375,0 -3.64584,1.75 -1.25,1.75 -1.25,5.08333 0,3.33334 1.25,5.08334 1.27084,1.75 3.64584,1.75 z"
+       style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:42.66666794px;font-family:Sans;-inkscape-font-specification:'Sans, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#008000"
+       id="path4639" />
+  </g>
+  <path
+     class="a"
+     d="M128,98.15c-3.78,6.36-7.41,12.52-11.11,18.64a4.87,4.87,0,0,0-.66,4.84A9.06,9.06,0,0,1,109.84,134c-5.24,1.37-10.35-2.07-11.39-7.68-.92-5,2.93-9.84,8.41-10.61.46-.07.93-.07,1.7-.13l8.33-14c-5.24-5.21-8.36-11.3-7.67-18.85A21.42,21.42,0,0,1,115.64,69a21.79,21.79,0,0,1,27.19-2.85c8.3,5.33,12.11,15.73,8.87,24.62l-7.61-2.07c1-4.94.27-9.39-3.07-13.19a13.17,13.17,0,0,0-8.25-4.31,13.41,13.41,0,0,0-14.65,9.49C116,87.87,119.22,93.73,128,98.15Z"
+     id="path4563" />
+  <path
+     class="b"
+     d="M138.84,90.63l8.06,14.21c13.56-4.2,23.78,3.31,27.45,11.35a21.92,21.92,0,0,1-7.3,27.19c-8.93,6.15-20.22,5.1-28.14-2.8l6.21-5.2c7.82,5.06,14.65,4.82,19.72-1.17a13.6,13.6,0,0,0-.22-17.74c-5.14-5.78-12-6-20.34-.41-3.45-6.12-7-12.2-10.3-18.36A6.27,6.27,0,0,0,129.06,94a8.74,8.74,0,0,1-7.14-8.46,9.22,9.22,0,0,1,16.41-6.25,8.68,8.68,0,0,1,1.6,8.59C139.64,88.74,139.25,89.59,138.84,90.63Z"
+     id="path4565" />
+  <path
+     class="c"
+     d="M145.21,129.08H128.88c-1.56,6.44-4.94,11.63-10.77,14.94a21.77,21.77,0,0,1-14.6,2.6,22.18,22.18,0,0,1-18.07-19.84,22,22,0,0,1,16.83-22.93l2.09,7.58c-9.23,4.71-12.42,10.64-9.84,18.06,2.27,6.53,8.73,10.11,15.75,8.72s10.77-7.36,10.33-16.92c6.79,0,13.58-.07,20.37,0,2.65,0,4.7-.23,6.7-2.57,3.29-3.85,9.34-3.5,12.88.13a9.25,9.25,0,1,1-13.37,12.79A31.92,31.92,0,0,1,145.21,129.08Z"
+     id="path4567" />
+  <path
+     class="b"
+     d="M197,96.06h6.27l3.6,17,3.69-17H217l-6.86,23.65H203.8l-3.71-17.23-3.75,17.23h-6.42l-6.64-23.65h6.64l3.69,17Z"
+     id="path4569" />
+  <path
+     class="b"
+     d="M234.22,96.54a9.62,9.62,0,0,1,4.09,3.5,11.63,11.63,0,0,1,1.88,4.89,30.27,30.27,0,0,1,.2,4.69H223.17q.14,3.56,2.47,5a6.28,6.28,0,0,0,3.41.89,4.8,4.8,0,0,0,4.71-2.71h6.31a7.72,7.72,0,0,1-2.29,4.27q-3.18,3.45-8.9,3.45a12.89,12.89,0,0,1-8.33-2.91q-3.61-2.91-3.61-9.47,0-6.15,3.26-9.42a11.4,11.4,0,0,1,8.46-3.28A13.45,13.45,0,0,1,234.22,96.54ZM225,101.89a6.42,6.42,0,0,0-1.65,3.66H234a5.16,5.16,0,0,0-1.65-3.74,5.42,5.42,0,0,0-3.67-1.28A4.9,4.9,0,0,0,225,101.89Z"
+     id="path4571" />
+  <path
+     class="b"
+     d="M263.21,98.91a13.59,13.59,0,0,1,2.65,8.68,15.2,15.2,0,0,1-2.62,9.14,9.5,9.5,0,0,1-12,2.45,9.4,9.4,0,0,1-2.31-2.43v3h-6.1V87.77H249V99.14a9.06,9.06,0,0,1,2.6-2.52,7.76,7.76,0,0,1,4.28-1.08A8.84,8.84,0,0,1,263.21,98.91ZM258,113.31a8.76,8.76,0,0,0,1.34-5.08,11.35,11.35,0,0,0-.66-4.17,4.57,4.57,0,0,0-4.59-3.12,4.64,4.64,0,0,0-4.66,3.06,11.39,11.39,0,0,0-.66,4.21,8.76,8.76,0,0,0,1.36,5,4.65,4.65,0,0,0,4.13,2A4.29,4.29,0,0,0,258,113.31Z"
+     id="path4573" />
+  <path
+     class="b"
+     d="M286,96.19a6.94,6.94,0,0,1,2.93,2.32,6.2,6.2,0,0,1,1.19,2.73,33.62,33.62,0,0,1,.22,4.57v13.9H284v-14.4a6.38,6.38,0,0,0-.65-3.08,3.24,3.24,0,0,0-3.19-1.65,4.37,4.37,0,0,0-3.69,1.64,7.55,7.55,0,0,0-1.26,4.68v12.82H269V87.84h6.18V99.12a7.58,7.58,0,0,1,3.1-2.87,8.73,8.73,0,0,1,3.71-.81A10,10,0,0,1,286,96.19Z"
+     id="path4575" />
+  <path
+     class="b"
+     d="M314.44,116.82q-3,3.7-9.09,3.7t-9.09-3.7a14.68,14.68,0,0,1,0-17.77q3-3.75,9.09-3.75t9.09,3.75a14.67,14.67,0,0,1,0,17.77Zm-4.95-3.42a9,9,0,0,0,1.45-5.48,8.92,8.92,0,0,0-1.45-5.47,5.5,5.5,0,0,0-8.34,0,8.86,8.86,0,0,0-1.46,5.47,8.91,8.91,0,0,0,1.46,5.48,5.48,5.48,0,0,0,8.34,0Z"
+     id="path4577" />
+  <path
+     class="b"
+     d="M340.37,116.82q-3,3.7-9.09,3.7t-9.09-3.7a14.67,14.67,0,0,1,0-17.77q3-3.75,9.09-3.75t9.09,3.75a14.67,14.67,0,0,1,0,17.77Zm-4.95-3.42a9,9,0,0,0,1.45-5.48,8.92,8.92,0,0,0-1.45-5.47,5.5,5.5,0,0,0-8.34,0,8.86,8.86,0,0,0-1.46,5.47,8.91,8.91,0,0,0,1.46,5.48,5.48,5.48,0,0,0,8.34,0Z"
+     id="path4579" />
+  <path
+     class="b"
+     d="M346.43,87.84h6.08v17.25l7.8-8.92H368L359.52,105l8.81,14.73H360.8l-5.73-10.11-2.56,2.66v7.44h-6.08Z"
+     id="path4581" />
+  <rect
+     style="fill:#008000;fill-opacity:1"
+     id="rect4618"
+     width="30.513918"
+     height="5.4246964"
+     x="394.70486"
+     y="115.01445" />
+</svg>