Compare commits

..

69 Commits

Author SHA1 Message Date
Adnan Hajdarević 7b8cc04992 Merge pull request #38 from adnanh/development
Fail if webhook cannot load hooks when not running in verbose mode (unless -nopanic flag is used)
2015-10-04 17:08:51 +02:00
Adnan Hajdarevic d4810bebdb Merge branch 'master' into development 2015-10-04 17:06:34 +02:00
Adnan Hajdarevic 272546bb2b add nopanic flag 2015-10-04 17:06:17 +02:00
Adnan Hajdarević 2afc6e6a54 Merge pull request #35 from ciarand/patch-1
Fix reverse build tag in webhook_windows.go
2015-07-23 17:08:10 +02:00
Ciaran Downey a5c92b88a6 Fix reverse build tag in webhook_windows.go
9c545a745f accidentally started negating
the build constraint in webhook_windows.go. This reverses that, fixing
the Windows build.
2015-07-22 14:58:54 -07:00
Adnan Hajdarević d12bbf4036 Update README.md 2015-06-10 09:33:15 +02:00
Adnan Hajdarević d91b34a316 Update README.md 2015-06-10 09:32:07 +02:00
Adnan Hajdarević 005e723b23 Merge pull request #34 from adnanh/development
Development
2015-06-06 14:28:54 +02:00
Adnan Hajdarevic 9977fa8c61 refactor 2015-06-06 14:28:00 +02:00
Adnan Hajdarevic cbe2440cda add entire query and headers as well 2015-06-06 14:25:32 +02:00
Adnan Hajdarevic 9c545a745f return command output, pass whole payload as json to the command 2015-06-06 14:19:52 +02:00
Adnan Hajdarević 263c75b1b5 Update README.md 2015-06-05 11:21:52 +02:00
Adnan Hajdarević 83cbffd37c Merge pull request #33 from gitter-badger/gitter-badge
Add a Gitter chat badge to README.md
2015-06-05 11:21:06 +02:00
The Gitter Badger b310b79fb8 Added Gitter badge 2015-06-05 09:15:27 +00:00
Adnan Hajdarevic f1ebc440a4 match all hooks with the same id 2015-05-27 09:16:26 +02:00
Adnan Hajdarević 10732bd57b Merge pull request #30 from adnanh/development
separated windows and other platforms to different files, removed sig…
2015-05-16 13:36:20 +02:00
Adnan Hajdarevic 4350685330 separated windows and other platforms to different files, removed signal watcher from windows build file so webhook can actually compile on windows, added string as a source, so you can pass static strings to your scripts without having to wrap them around with other scripts 2015-05-16 13:32:21 +02:00
Adnan Hajdarević 6053f48b23 Merge pull request #28 from kevinlebrun/fix-for-osx-signals
Fix for OS X USR1 signal
2015-04-06 16:07:39 +02:00
Kevin Le Brun 6cd8258651 Fix for OS X USR1 signal
It seems that signals code for Linux and OS X (FreeBSD) are different. I
rely on `syscall.SIGUSR1` which should be cross-compatible.

Tested with `kill -usr1 <pid>` on OS X 10.10
2015-04-05 23:21:40 +02:00
Adnan Hajdarević fb71ea0fae Update README.md 2015-03-31 22:24:27 +02:00
Adnan Hajdarević aeacb6dac7 Merge pull request #24 from adnanh/development
added hook reload on USR1 signal
2015-03-31 22:14:47 +02:00
Adnan Hajdarevic 1039151a16 added hook reload on USR1 signal 2015-03-31 22:10:35 +02:00
Adnan Hajdarević db928228c8 bumping up the minor version 2015-03-30 15:33:15 +02:00
Adnan Hajdarević 6896a34aab Merge pull request #21 from kevinlebrun/master
Allow charset to be defined in Content-Type header
2015-03-30 00:22:29 +02:00
Kevin Le Brun 5f853d8aba Allow charset to be defined in Content-Type header
The payload couldn't be parsed when charset was present in the
`Content-Type` header. The content type should begin with the MIME type
so we now check if the content type starts with `application/json` or
`application/x-www-form-urlencoded`.

This closes #20
2015-03-29 20:01:56 +02:00
Adnan Hajdarević 12c48f87cb Merge pull request #19 from moorereason/test-json
Add tests for ParseJSONParameters
2015-03-21 10:28:20 +01:00
Adnan Hajdarević acf38c3210 Merge pull request #18 from moorereason/extract-tests
Add new tests for ExtractParameter
2015-03-21 10:26:36 +01:00
Cameron Moore d3f368cb8f Add tests for ParseJSONParameters
This commit adds 100% coverage to Hook.ParaseJSONParameters.
2015-03-20 23:22:30 -05:00
Cameron Moore 943bc258f7 Add new tests for ExtractParameter
This commit regains 100% coverage for ExtractParameter after changes
from commit 688483d.
2015-03-20 22:43:39 -05:00
Adnan Hajdarevic 231426da57 removed leftover debug trace, fixed a panic bug 2015-03-21 01:26:04 +01:00
Adnan Hajdarević baec1cadc5 Merge pull request #17 from adnanh/json-parameters
added parse-parameters-as-json property to hooks, fixed some bugs in old...
2015-03-21 01:18:44 +01:00
Adnan Hajdarevic 688483d6d1 added parse-parameters-as-json property to hooks, fixed some bugs in old code 2015-03-21 01:10:17 +01:00
Adnan Hajdarević bddb523b67 Merge pull request #16 from moorereason/fix-notrule
Fix infinite loop in NotRule.Evaluate
2015-03-20 23:37:49 +01:00
Adnan Hajdarević b8807ed434 Merge pull request #15 from moorereason/fix-negindex
Only support unsigned ints for slice indexes
2015-03-20 23:31:42 +01:00
Adnan Hajdarević 8527a9b23e Merge pull request #14 from moorereason/hook-tests
Complete hook test suite
2015-03-20 23:31:23 +01:00
Cameron Moore becd8935be Fix infinite loop in NotRule.Evaluate 2015-03-20 16:58:58 -05:00
Cameron Moore 230d16dd93 Only support unsigned ints for slice indexes
This commit changes ExtractParameter to parse slice indexes as unsigned
ints.  Fixes test case "a.-1.b".
2015-03-20 16:54:03 -05:00
Cameron Moore 9a7dedbc09 Complete hook test suite
This commit provides 100% test coverage of the hook package.

Two bugs were discovered with these tests.  I'll be submitting separate
commits to fix those issues.
2015-03-20 16:46:08 -05:00
Adnan Hajdarević 84ce6f262a Merge pull request #13 from moorereason/refactor-helpers
Refactor to remove helpers package
2015-03-20 15:05:07 +01:00
Cameron Moore 7dd55f5232 Refactor to remove helpers package
This commit removes the "helpers" package by moving functions from the
package into the other packages that use them.

CheckPayloadSignature() and ExtractParamater() are simply moved to the
"hook" package.  I'm not sure of the usefulness of having these
functions exported, but I left them allow for now.

ValuesToMap() is moved to the "main" webhook package and renamed to
valuesToMap().

Tests were moved into the "hook" package since we only test
ExtractParameter() right now.

This commit closes adnanh/webhook#12.
2015-03-20 08:55:42 -05:00
Adnan Hajdarević d8a21582a3 Merge pull request #11 from moorereason/fix-extractparam
Fix ExtractParameter and add tests
2015-03-19 20:29:14 +01:00
Cameron Moore 8a627f7e67 Fix slice traversal in ExtractParameter
With these changes, I'm able to pass tests "a.1.b" and "a.1.b.c".
2015-03-19 12:15:37 -05:00
Cameron Moore 7635cfde33 Add another slice test to ExtractParameter tests 2015-03-19 11:55:24 -05:00
Cameron Moore 2a2a20dcb8 Add tests for ExtractParameter 2015-03-19 11:32:10 -05:00
Adnan Hajdarević 10755eb9d9 Merge pull request #9 from moorereason/vet
Cleanups from static analyzers
2015-03-19 16:31:24 +01:00
Cameron Moore e9aaeb579e Rework negroni Logger creation
From `go vet`:

webhook.go:98: github.com/codegangsta/negroni.Logger composite literal uses unkeyed fields
2015-03-19 09:23:47 -05:00
Cameron Moore 956589fab3 Improve placement of variable declaration
Thanks to rsc.io/grind
2015-03-19 09:21:15 -05:00
Adnan Hajdarevic f83af97138 added per-hook defined response message 2015-03-17 19:34:54 +01:00
Adnan Hajdarevic 5a96a5721a added custom url prefix for served hooks url path 2015-03-17 19:05:18 +01:00
Adnan Hajdarević 37b310feaa Merge pull request #4 from adnanh/development
added support for https
2015-03-14 13:06:14 +01:00
Adnan Hajdarevic ad76b51e6a updated minor version to 2.2.0 2015-03-14 13:04:28 +01:00
Adnan Hajdarevic 4304aaa2d5 readme update 2015-03-14 13:03:51 +01:00
Adnan Hajdarevic a2fac2373b updated readme to include https 2015-03-14 13:02:41 +01:00
Adnan Hajdarevic c9adceb5d8 added support for https 2015-03-14 12:59:54 +01:00
Adnan Hajdarevic 8488d3c432 Merge branch 'master' of github.com:adnanh/webhook 2015-03-14 12:44:24 +01:00
Adnan Hajdarevic d3f5da5489 bumped up the minor version to 2.1.0 2015-03-14 12:44:05 +01:00
Adnan Hajdarević 59b4954845 Update README.md 2015-03-14 12:42:59 +01:00
Adnan Hajdarević 36aea82855 Merge pull request #3 from adnanh/development
added ability to hot reload the hooks file
2015-03-14 12:39:05 +01:00
Adnan Hajdarevic 652109d46e added ability to hot reload the hooks file 2015-03-14 12:37:45 +01:00
Adnan Hajdarevic a7bbff0963 fixed invalid parameter name in example file 2015-03-13 01:59:10 +01:00
Adnan Hajdarevic 0b269e4870 fixed payload-hash-sha1 for github header 2015-03-13 01:54:23 +01:00
Adnan Hajdarević 40d01e4d5a typo 2015-03-13 01:43:44 +01:00
Adnan Hajdarevic 90528b2ed9 webhook 2.0.0 2015-03-13 01:31:49 +01:00
Adnan Hajdarevic 489750a710 added logger 2015-03-12 13:09:50 +01:00
Adnan Hajdarevic b332c9e715 added flags 2015-03-11 17:08:53 +01:00
Adnan Hajdarevic bacf2b3666 negroni boilerplate 2015-03-11 16:48:52 +01:00
Adnan Hajdarevic c96b0497e4 updates to README wiki links 2015-03-11 02:23:16 +01:00
Adnan Hajdarevic 56560d3809 merge 2015-03-11 02:21:00 +01:00
Adnan Hajdarevic d858b54309 new path 2015-03-11 02:19:32 +01:00
10 changed files with 1444 additions and 772 deletions
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015 Adnan Hajdarevic <adnanh@gmail.com>
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.
+72 -135
View File
@@ -1,154 +1,91 @@
# webhook
[![Join the chat at https://gitter.im/adnanh/webhook](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/adnanh/webhook?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Flattr this](https://button.flattr.com/flattr-badge-large.png)](https://flattr.com/submit/auto?user_id=adnanh&url=https%3A%2F%2Fwww.github.com%2Fadnanh%2Fwebhook)
## Installing
*Please note:* Before installing the webhook, make sure you have installed `go` and properly set up your `$GOPATH` environment variable.
# What is webhook?
[webhook](https://github.com/adnanh/webhook/) is a lightweight configurable tool written in Go, that allows you to easily create HTTP endpoints (hooks) on your server, which you can use to execute configured commands. You can also pass data from the HTTP request (such as headers, payload or query variables) to your commands. [webhook](https://github.com/adnanh/webhook/) also allows you to specify rules which have to be satisfied in order for the hook to be triggered.
```go
For example, if you're using Github or Bitbucket, you can use [webhook](https://github.com/adnanh/webhook/) to set up a hook that runs a redeploy script for your project on your staging server, whenever you push changes to the master branch of your project.
If you use Slack, you can set up an "Outgoing webhook integration" to run various commands on your server, which can then report back directly to your Slack channels using the "Incoming webhook integrations".
[webhook](https://github.com/adnanh/webhook/) aims to do nothing more than it should do, and that is:
1. receive the request,
2. parse the headers, payload and query variables,
3. check if the specified rules for the hook are satisfied,
3. and finally, pass the specified arguments to the specified command.
Everything else is the responsibility of the command's author.
---
# Getting started
To get started, first make sure you've properly set up your [Golang](http://golang.org/doc/install) environment and then run the
```bash
$ go get github.com/adnanh/webhook
```
to get the latest version of the [webhook](https://github.com/adnanh/webhook/).
## Updating
```go
$ go get -u github.com/adnanh/webhook
```
Next step is to define some hooks you want [webhook](https://github.com/adnanh/webhook/) to serve. Begin by creating an empty file named `hooks.json`. This file will contain an array of hooks the [webhook](https://github.com/adnanh/webhook/) will serve. Check [Hook definition page](https://github.com/adnanh/webhook/wiki/Hook-Definition) to see the detailed description of what properties a hook can contain, and how to use them.
## Adding hooks
Hooks are defined using JSON format. The _hooks file_ must contain an array of JSON formatted hooks. Here is an example of a valid _hooks file_ containing only one hook. The hook will be triggered whenever a push to the master branch occurrs.
Let's define a simple hook named `redeploy-webhook` that will run a redeploy script located in `/var/scripts/redeploy.sh`.
Our `hooks.json` file will now look like this:
```json
[
{
"id": "hook-1",
"command": "OS command to be executed when the hook gets triggered",
"args": [
"ref",
"repository.owner.name"
],
"cwd": "current working directory under which the specified command will be executed (optional, defaults to the directory where the binary resides)",
"secret": "secret key used to compute the hash of the payload (optional)",
"trigger-rule":
{
"match":
{
"parameter": "ref",
"value": "refs/heads/master"
}
}
"id": "redeploy-webhook",
"execute-command": "/var/scripts/redeploy.sh",
"command-working-directory": "/var/webhook"
}
]
```
## Passing parameters to the command
If you wish to pass parameters from the payload to the command that will be executed, you can use the `args` field in the `hook` definition. The parameters will be passed in order they are specified. If the payload does not contain the specified parameter, an empty string will be passed to the command instead.
## Trigger rules
### And
*And rule* will evaluate to _true_, if and only if all of the sub rules evaluate to _true_.
```json
{
"and":
[
{
"match":
{
"parameter": "ref",
"value": "refs/heads/master"
}
},
{
"match":
{
"parameter": "repository.owner.name",
"value": "adnanh"
}
}
]
}
```
### Or
*Or rule* will evaluate to _true_, if any of the sub rules evaluate to _true_.
```json
{
"or":
[
{
"match":
{
"parameter": "ref",
"value": "refs/heads/master"
}
},
{
"match":
{
"parameter": "ref",
"value": "refs/heads/development"
}
}
]
}
```
### Not
*Not rule* will evaluate to _true_, if and only if the sub rule evaluates to _false_.
```json
{
"not":
{
"match":
{
"parameter": "ref",
"value": "refs/heads/master"
}
}
}
```
### Match
*Match rule* will evaluate to _true_, if and only if the payload JSON object contains the key specified in the `parameter` field that has the same value as specified in the `value` field.
*Please note:* Due to technical reasons, _number_ and _boolean_ values in the _hooks file_ must be wrapped around with a pair of quotes.
```json
{
"match":
{
"parameter": "repository.id",
"value": "123456"
}
}
```
It is possible to specify the values deeper in the payload JSON object with the dot operator, and if a value of the specified key happens to be an array, it's possible to index the array values by using the number instead of a string as the key, which is shown in the following example:
```json
{
"match":
{
"parameter": "commits.0.author.username",
"value": "adnanh"
}
}
```
## Running
After installing webhook, in your `$GOPATH/bin` directory you should have `webhook` binary.
By simply running the binary using the `./webhook` command, the webhook will start with the default options.
That means the webhook will listen on _all interfaces_ on port `9000`. It will try to read and parse `hooks.json` file from the same directory where the binary is located, and it will log everything to `stdout` and the file `webhook.log`.
To override any of these options, you can use the following command line flags:
You can now run [webhook](https://github.com/adnanh/webhook/) using
```bash
-hooks="hooks.json": path to the json file containing defined hooks the webhook should serve
-ip="": ip the webhook server should listen on
-log="webhook.log": path to the log file
-port=9000: port the webhook server should listen on
$ /path/to/webhook -hooks hooks.json -verbose
```
All hooks are served under the `http://ip:port/hook/:id`, where the `:id` corresponds to the hook *id* specified in _hooks file_.
It will start up on default port 9000 and will provide you with one HTTP endpoint
```http
http://yourserver:9000/hooks/redeploy-webhook
```
Visiting `http://ip:port` will show version, uptime and number of hooks the webhook is serving.
Check [webhook parameters page](https://github.com/adnanh/webhook/wiki/Webhook-Parameters) to see how to override the ip, port and other settings such as hook hotreload, verbose output, etc, when starting the [webhook](https://github.com/adnanh/webhook/).
## Todo
* Architecture overhaul to support vendor specific handlers and hook options
* Add support for ip white/black listing
* Add "match-header" rule
* Add "match-regex" rule
* Update README file with the description of what the tool is really trying to achieve and provide some real-life examples of usage
* Move the specific details to their respective wiki pages
* ???
By performing a simple HTTP GET or POST request to that endpoint, your specified redeploy script would be executed. Neat!
However, hook defined like that could pose a security threat to your system, because anyone who knows your endpoint, can send a request and execute your command. To prevent that, you can use the `"trigger-rule"` property for your hook, to specify the exact circumstances under which the hook would be triggered. For example, you can use them to add a secret that you must supply as a parameter in order to successfully trigger the hook. Please check out the [Hook rules page](https://github.com/adnanh/webhook/wiki/Hook-Rules) for detailed list of available rules and their usage.
# Using HTTPS
[webhook](https://github.com/adnanh/webhook/) by default serves hooks using http. If you want [webhook](https://github.com/adnanh/webhook/) to serve secure content using https, you can use the `-secure` flag while starting [webhook](https://github.com/adnanh/webhook/). Files containing a certificate and matching private key for the server must be provided using the `-cert /path/to/cert.pem` and `-key /path/to/key.pem` flags. If the certificate is signed by a certificate authority, the cert file should be the concatenation of the server's certificate followed by the CA's certificate.
# Examples
Check out [Hook examples page](https://github.com/adnanh/webhook/wiki/Hook-Examples) for more complex examples of hooks.
# Contributing
Any form of contribution is welcome and highly appreciated.
Big thanks to [all the current contributors](https://github.com/adnanh/webhook/graphs/contributors) for their contributions!
# License
The MIT License (MIT)
Copyright (c) 2015 Adnan Hajdarevic <adnanh@gmail.com>
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.
-70
View File
@@ -1,70 +0,0 @@
package helpers
import (
"crypto/hmac"
"crypto/sha1"
"encoding/hex"
"fmt"
"net/url"
"reflect"
"strconv"
"strings"
)
// CheckPayloadSignature calculates and verifies SHA1 signature of the given payload
func CheckPayloadSignature(payload []byte, secret string, signature string) (string, bool) {
mac := hmac.New(sha1.New, []byte(secret))
mac.Write(payload)
expectedMAC := hex.EncodeToString(mac.Sum(nil))
return expectedMAC, hmac.Equal([]byte(signature), []byte(expectedMAC))
}
// FormValuesToMap converts url.Values to a map[string]interface{} object
func FormValuesToMap(formValues url.Values) map[string]interface{} {
ret := make(map[string]interface{})
for key, value := range formValues {
if len(value) > 0 {
ret[key] = value[0]
}
}
return ret
}
// ExtractJSONParameter extracts value from payload based on the passed string
func ExtractJSONParameter(s string, params interface{}) (string, bool) {
var p []string
if paramsValue := reflect.ValueOf(params); paramsValue.Kind() == reflect.Slice {
if paramsValueSliceLength := paramsValue.Len(); paramsValueSliceLength > 0 {
if p = strings.SplitN(s, ".", 3); len(p) > 3 {
index, err := strconv.ParseInt(p[1], 10, 64)
if err != nil {
return "", false
} else if paramsValueSliceLength <= int(index) {
return "", false
}
return ExtractJSONParameter(p[2], params.([]map[string]interface{})[index])
}
}
return "", false
}
if p = strings.SplitN(s, ".", 2); len(p) > 1 {
if pValue, ok := params.(map[string]interface{})[p[0]]; ok {
return ExtractJSONParameter(p[1], pValue)
}
} else {
if pValue, ok := params.(map[string]interface{})[p[0]]; ok {
return fmt.Sprintf("%v", pValue), true
}
}
return "", false
}
+422
View File
@@ -0,0 +1,422 @@
package hook
import (
"crypto/hmac"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"reflect"
"regexp"
"strconv"
"strings"
)
// Constants used to specify the parameter source
const (
SourceHeader string = "header"
SourceQuery string = "url"
SourcePayload string = "payload"
SourceString string = "string"
SourceEntirePayload string = "entire-payload"
SourceEntireQuery string = "entire-query"
SourceEntireHeaders string = "entire-headers"
)
// CheckPayloadSignature calculates and verifies SHA1 signature of the given payload
func CheckPayloadSignature(payload []byte, secret string, signature string) (string, bool) {
if strings.HasPrefix(signature, "sha1=") {
signature = signature[5:]
}
mac := hmac.New(sha1.New, []byte(secret))
mac.Write(payload)
expectedMAC := hex.EncodeToString(mac.Sum(nil))
return expectedMAC, hmac.Equal([]byte(signature), []byte(expectedMAC))
}
// ReplaceParameter replaces parameter value with the passed value in the passed map
// (please note you should pass pointer to the map, because we're modifying it)
// based on the passed string
func ReplaceParameter(s string, params interface{}, value interface{}) bool {
if params == nil {
return false
}
if paramsValue := reflect.ValueOf(params); paramsValue.Kind() == reflect.Slice {
if paramsValueSliceLength := paramsValue.Len(); paramsValueSliceLength > 0 {
if p := strings.SplitN(s, ".", 2); len(p) > 1 {
index, err := strconv.ParseUint(p[0], 10, 64)
if err != nil || paramsValueSliceLength <= int(index) {
return false
}
return ReplaceParameter(p[1], params.([]interface{})[index], value)
}
}
return false
}
if p := strings.SplitN(s, ".", 2); len(p) > 1 {
if pValue, ok := params.(map[string]interface{})[p[0]]; ok {
return ReplaceParameter(p[1], pValue, value)
}
} else {
if _, ok := (*params.(*map[string]interface{}))[p[0]]; ok {
(*params.(*map[string]interface{}))[p[0]] = value
return true
}
}
return false
}
// GetParameter extracts interface{} value based on the passed string
func GetParameter(s string, params interface{}) (interface{}, bool) {
if params == nil {
return nil, false
}
if paramsValue := reflect.ValueOf(params); paramsValue.Kind() == reflect.Slice {
if paramsValueSliceLength := paramsValue.Len(); paramsValueSliceLength > 0 {
if p := strings.SplitN(s, ".", 2); len(p) > 1 {
index, err := strconv.ParseUint(p[0], 10, 64)
if err != nil || paramsValueSliceLength <= int(index) {
return nil, false
}
return GetParameter(p[1], params.([]interface{})[index])
}
index, err := strconv.ParseUint(s, 10, 64)
if err != nil || paramsValueSliceLength <= int(index) {
return nil, false
}
return params.([]interface{})[index], true
}
return nil, false
}
if p := strings.SplitN(s, ".", 2); len(p) > 1 {
if paramsValue := reflect.ValueOf(params); paramsValue.Kind() == reflect.Map {
if pValue, ok := params.(map[string]interface{})[p[0]]; ok {
return GetParameter(p[1], pValue)
}
} else {
return nil, false
}
} else {
if pValue, ok := params.(map[string]interface{})[p[0]]; ok {
return pValue, true
}
}
return nil, false
}
// ExtractParameterAsString extracts value from interface{} as string based on the passed string
func ExtractParameterAsString(s string, params interface{}) (string, bool) {
if pValue, ok := GetParameter(s, params); ok {
return fmt.Sprintf("%v", pValue), true
}
return "", false
}
// Argument type specifies the parameter key name and the source it should
// be extracted from
type Argument struct {
Source string `json:"source"`
Name string `json:"name"`
}
// Get Argument method returns the value for the Argument's key name
// based on the Argument's source
func (ha *Argument) Get(headers, query, payload *map[string]interface{}) (string, bool) {
var source *map[string]interface{}
switch ha.Source {
case SourceHeader:
source = headers
case SourceQuery:
source = query
case SourcePayload:
source = payload
case SourceString:
return ha.Name, true
case SourceEntirePayload:
r, err := json.Marshal(payload)
if err != nil {
return "", false
}
return string(r), true
case SourceEntireHeaders:
r, err := json.Marshal(headers)
if err != nil {
return "", false
}
return string(r), true
case SourceEntireQuery:
r, err := json.Marshal(query)
if err != nil {
return "", false
}
return string(r), true
}
if source != nil {
return ExtractParameterAsString(ha.Name, *source)
}
return "", false
}
// Hook type is a structure containing details for a single hook
type Hook struct {
ID string `json:"id"`
ExecuteCommand string `json:"execute-command"`
CommandWorkingDirectory string `json:"command-working-directory"`
ResponseMessage string `json:"response-message"`
CaptureCommandOutput bool `json:"include-command-output-in-response"`
PassArgumentsToCommand []Argument `json:"pass-arguments-to-command"`
JSONStringParameters []Argument `json:"parse-parameters-as-json"`
TriggerRule *Rules `json:"trigger-rule"`
}
// ParseJSONParameters decodes specified arguments to JSON objects and replaces the
// string with the newly created object
func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface{}) {
for i := range h.JSONStringParameters {
if arg, ok := h.JSONStringParameters[i].Get(headers, query, payload); ok {
var newArg map[string]interface{}
decoder := json.NewDecoder(strings.NewReader(string(arg)))
decoder.UseNumber()
err := decoder.Decode(&newArg)
if err != nil {
log.Printf("error parsing argument as JSON payload %+v\n", err)
} else {
var source *map[string]interface{}
switch h.JSONStringParameters[i].Source {
case SourceHeader:
source = headers
case SourcePayload:
source = payload
case SourceQuery:
source = query
}
if source != nil {
ReplaceParameter(h.JSONStringParameters[i].Name, source, newArg)
} else {
log.Printf("invalid source for argument %+v\n", h.JSONStringParameters[i])
}
}
} else {
log.Printf("couldn't retrieve argument for %+v\n", h.JSONStringParameters[i])
}
}
}
// ExtractCommandArguments creates a list of arguments, based on the
// PassArgumentsToCommand property that is ready to be used with exec.Command()
func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]interface{}) []string {
var args = make([]string, 0)
args = append(args, h.ExecuteCommand)
for i := range h.PassArgumentsToCommand {
if arg, ok := h.PassArgumentsToCommand[i].Get(headers, query, payload); ok {
args = append(args, arg)
} else {
args = append(args, "")
log.Printf("couldn't retrieve argument for %+v\n", h.PassArgumentsToCommand[i])
}
}
return args
}
// Hooks is an array of Hook objects
type Hooks []Hook
// LoadFromFile attempts to load hooks from specified JSON file
func (h *Hooks) LoadFromFile(path string) error {
if path == "" {
return nil
}
// parse hook file for hooks
file, e := ioutil.ReadFile(path)
if e != nil {
return e
}
e = json.Unmarshal(file, h)
return e
}
// Match iterates through Hooks and returns first one that matches the given ID,
// if no hook matches the given ID, nil is returned
func (h *Hooks) Match(id string) *Hook {
for i := range *h {
if (*h)[i].ID == id {
return &(*h)[i]
}
}
return nil
}
// MatchAll iterates through Hooks and returns all of the hooks that match the
// given ID, if no hook matches the given ID, nil is returned
func (h *Hooks) MatchAll(id string) []*Hook {
matchedHooks := make([]*Hook, 0)
for i := range *h {
if (*h)[i].ID == id {
matchedHooks = append(matchedHooks, &(*h)[i])
}
}
if len(matchedHooks) > 0 {
return matchedHooks
}
return nil
}
// Rules is a structure that contains one of the valid rule types
type Rules struct {
And *AndRule `json:"and"`
Or *OrRule `json:"or"`
Not *NotRule `json:"not"`
Match *MatchRule `json:"match"`
}
// Evaluate finds the first rule property that is not nil and returns the value
// it evaluates to
func (r Rules) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) bool {
switch {
case r.And != nil:
return r.And.Evaluate(headers, query, payload, body)
case r.Or != nil:
return r.Or.Evaluate(headers, query, payload, body)
case r.Not != nil:
return r.Not.Evaluate(headers, query, payload, body)
case r.Match != nil:
return r.Match.Evaluate(headers, query, payload, body)
}
return false
}
// AndRule will evaluate to true if and only if all of the ChildRules evaluate to true
type AndRule []Rules
// Evaluate AndRule will return true if and only if all of ChildRules evaluate to true
func (r AndRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) bool {
res := true
for _, v := range r {
res = res && v.Evaluate(headers, query, payload, body)
if res == false {
return res
}
}
return res
}
// OrRule will evaluate to true if any of the ChildRules evaluate to true
type OrRule []Rules
// Evaluate OrRule will return true if any of ChildRules evaluate to true
func (r OrRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) bool {
res := false
for _, v := range r {
res = res || v.Evaluate(headers, query, payload, body)
if res == true {
return res
}
}
return res
}
// NotRule will evaluate to true if any and only if the ChildRule evaluates to false
type NotRule Rules
// Evaluate NotRule will return true if and only if ChildRule evaluates to false
func (r NotRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) bool {
return !Rules(r).Evaluate(headers, query, payload, body)
}
// MatchRule will evaluate to true based on the type
type MatchRule struct {
Type string `json:"type"`
Regex string `json:"regex"`
Secret string `json:"secret"`
Value string `json:"value"`
Parameter Argument `json:"parameter"`
}
// Constants for the MatchRule type
const (
MatchValue string = "value"
MatchRegex string = "regex"
MatchHashSHA1 string = "payload-hash-sha1"
)
// Evaluate MatchRule will return based on the type
func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) bool {
if arg, ok := r.Parameter.Get(headers, query, payload); ok {
switch r.Type {
case MatchValue:
return arg == r.Value
case MatchRegex:
ok, err := regexp.MatchString(r.Regex, arg)
if err != nil {
log.Printf("error while trying to evaluate regex: %+v", err)
}
return ok
case MatchHashSHA1:
expected, ok := CheckPayloadSignature(*body, r.Secret, arg)
if !ok {
log.Printf("payload signature mismatch, expected %s got %s", expected, arg)
}
return ok
}
} else {
log.Printf("couldn't retrieve argument for %+v\n", r.Parameter)
}
return false
}
// CommandStatusResponse type encapsulates the executed command exit code, message, stdout and stderr
type CommandStatusResponse struct {
ResponseMessage string `json:"message"`
Output string `json:"output"`
Error string `json:"error"`
}
+329
View File
@@ -0,0 +1,329 @@
package hook
import (
"reflect"
"testing"
)
var checkPayloadSignatureTests = []struct {
payload []byte
secret string
signature string
mac string
ok bool
}{
{[]byte(`{"a": "z"}`), "secret", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", true},
{[]byte(`{"a": "z"}`), "secret", "sha1=b17e04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", true},
// failures
{[]byte(`{"a": "z"}`), "secret", "XXXe04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", false},
{[]byte(`{"a": "z"}`), "secreX", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", "900225703e9342328db7307692736e2f7cc7b36e", false},
}
func TestCheckPayloadSignature(t *testing.T) {
for _, tt := range checkPayloadSignatureTests {
mac, ok := CheckPayloadSignature(tt.payload, tt.secret, tt.signature)
if ok != tt.ok || mac != tt.mac {
t.Errorf("failed to check payload signature {%q, %q, %q}:\nexpected {mac:%#v, ok:%#v},\ngot {mac:%#v, ok:%#v}", tt.payload, tt.secret, tt.signature, tt.mac, tt.ok, mac, ok)
}
}
}
var extractParameterTests = []struct {
s string
params interface{}
value string
ok bool
}{
{"a", map[string]interface{}{"a": "z"}, "z", true},
{"a.b", map[string]interface{}{"a": map[string]interface{}{"b": "z"}}, "z", true},
{"a.b.c", map[string]interface{}{"a": map[string]interface{}{"b": map[string]interface{}{"c": "z"}}}, "z", true},
{"a.b.0", map[string]interface{}{"a": map[string]interface{}{"b": []interface{}{"x", "y", "z"}}}, "x", true},
{"a.1.b", map[string]interface{}{"a": []interface{}{map[string]interface{}{"b": "y"}, map[string]interface{}{"b": "z"}}}, "z", true},
{"a.1.b.c", map[string]interface{}{"a": []interface{}{map[string]interface{}{"b": map[string]interface{}{"c": "y"}}, map[string]interface{}{"b": map[string]interface{}{"c": "z"}}}}, "z", true},
// failures
{"check_nil", nil, "", false},
{"a.X", map[string]interface{}{"a": map[string]interface{}{"b": "z"}}, "", false}, // non-existent parameter reference
{"a.X.c", map[string]interface{}{"a": []interface{}{map[string]interface{}{"b": "y"}, map[string]interface{}{"b": "z"}}}, "", false}, // non-integer slice index
{"a.-1.b", map[string]interface{}{"a": []interface{}{map[string]interface{}{"b": "y"}, map[string]interface{}{"b": "z"}}}, "", false}, // negative slice index
{"a.500.b", map[string]interface{}{"a": map[string]interface{}{"b": "z"}}, "", false}, // non-existent slice
{"a.501.b", map[string]interface{}{"a": []interface{}{map[string]interface{}{"b": "y"}, map[string]interface{}{"b": "z"}}}, "", false}, // non-existent slice index
{"a.502.b", map[string]interface{}{"a": []interface{}{}}, "", false}, // non-existent slice index
{"a.b.503", map[string]interface{}{"a": map[string]interface{}{"b": []interface{}{"x", "y", "z"}}}, "", false}, // trailing, non-existent slice index
{"a.b", interface{}("a"), "", false}, // non-map, non-slice input
}
func TestExtractParameter(t *testing.T) {
for _, tt := range extractParameterTests {
value, ok := ExtractParameterAsString(tt.s, tt.params)
if ok != tt.ok || value != tt.value {
t.Errorf("failed to extract parameter %q:\nexpected {value:%#v, ok:%#v},\ngot {value:%#v, ok:%#v}", tt.s, tt.value, tt.ok, value, ok)
}
}
}
var argumentGetTests = []struct {
source, name string
headers, query, payload *map[string]interface{}
value string
ok bool
}{
{"header", "a", &map[string]interface{}{"a": "z"}, nil, nil, "z", true},
{"url", "a", nil, &map[string]interface{}{"a": "z"}, nil, "z", true},
{"payload", "a", nil, nil, &map[string]interface{}{"a": "z"}, "z", true},
{"string", "a", nil, nil, &map[string]interface{}{"a": "z"}, "a", true},
// failures
{"header", "a", nil, &map[string]interface{}{"a": "z"}, &map[string]interface{}{"a": "z"}, "", false}, // nil headers
{"url", "a", &map[string]interface{}{"a": "z"}, nil, &map[string]interface{}{"a": "z"}, "", false}, // nil query
{"payload", "a", &map[string]interface{}{"a": "z"}, &map[string]interface{}{"a": "z"}, nil, "", false}, // nil payload
{"foo", "a", &map[string]interface{}{"a": "z"}, nil, nil, "", false}, // invalid source
}
func TestArgumentGet(t *testing.T) {
for _, tt := range argumentGetTests {
a := Argument{tt.source, tt.name}
value, ok := a.Get(tt.headers, tt.query, tt.payload)
if ok != tt.ok || value != tt.value {
t.Errorf("failed to get {%q, %q}:\nexpected {value:%#v, ok:%#v},\ngot {value:%#v, ok:%#v}", tt.source, tt.name, tt.value, tt.ok, value, ok)
}
}
}
var hookParseJSONParametersTests = []struct {
params []Argument
headers, query, payload *map[string]interface{}
rheaders, rquery, rpayload *map[string]interface{}
}{
{[]Argument{Argument{"header", "a"}}, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, nil, nil},
{[]Argument{Argument{"url", "a"}}, nil, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, nil},
{[]Argument{Argument{"payload", "a"}}, nil, nil, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}},
{[]Argument{Argument{"header", "z"}}, &map[string]interface{}{"z": `{}`}, nil, nil, &map[string]interface{}{"z": map[string]interface{}{}}, nil, nil},
// failures
{[]Argument{Argument{"header", "z"}}, &map[string]interface{}{"z": ``}, nil, nil, &map[string]interface{}{"z": ``}, nil, nil}, // empty string
{[]Argument{Argument{"header", "y"}}, &map[string]interface{}{"X": `{}`}, nil, nil, &map[string]interface{}{"X": `{}`}, nil, nil}, // missing parameter
{[]Argument{Argument{"string", "z"}}, &map[string]interface{}{"z": ``}, nil, nil, &map[string]interface{}{"z": ``}, nil, nil}, // invalid argument source
}
func TestHookParseJSONParameters(t *testing.T) {
for _, tt := range hookParseJSONParametersTests {
h := &Hook{JSONStringParameters: tt.params}
h.ParseJSONParameters(tt.headers, tt.query, tt.payload)
if !reflect.DeepEqual(tt.headers, tt.rheaders) {
t.Errorf("failed to parse %v:\nexpected %#v,\ngot %#v", tt.params, *tt.rheaders, *tt.headers)
}
}
}
var hookExtractCommandArgumentsTests = []struct {
exec string
args []Argument
headers, query, payload *map[string]interface{}
value []string
}{
{"test", []Argument{Argument{"header", "a"}}, &map[string]interface{}{"a": "z"}, nil, nil, []string{"test", "z"}},
// failures
{"fail", []Argument{Argument{"payload", "a"}}, &map[string]interface{}{"a": "z"}, nil, nil, []string{"fail", ""}},
}
func TestHookExtractCommandArguments(t *testing.T) {
for _, tt := range hookExtractCommandArgumentsTests {
h := &Hook{ExecuteCommand: tt.exec, PassArgumentsToCommand: tt.args}
value := h.ExtractCommandArguments(tt.headers, tt.query, tt.payload)
if !reflect.DeepEqual(value, tt.value) {
t.Errorf("failed to extract args {cmd=%q, args=%v}:\nexpected %#v,\ngot %#v", tt.exec, tt.args, tt.value, value)
}
}
}
var hooksLoadFromFileTests = []struct {
path string
ok bool
}{
{"../hooks.json.example", true},
{"", true},
// failures
{"missing.json", false},
}
func TestHooksLoadFromFile(t *testing.T) {
for _, tt := range hooksLoadFromFileTests {
h := &Hooks{}
err := h.LoadFromFile(tt.path)
if (err == nil) != tt.ok {
t.Errorf(err.Error())
}
}
}
var hooksMatchTests = []struct {
id string
hooks Hooks
value *Hook
}{
{"a", Hooks{Hook{ID: "a"}}, &Hook{ID: "a"}},
{"X", Hooks{Hook{ID: "a"}}, new(Hook)},
}
func TestHooksMatch(t *testing.T) {
for _, tt := range hooksMatchTests {
value := tt.hooks.Match(tt.id)
if reflect.DeepEqual(reflect.ValueOf(value), reflect.ValueOf(tt.value)) {
t.Errorf("failed to match %q:\nexpected %#v,\ngot %#v", tt.id, tt.value, value)
}
}
}
var matchRuleTests = []struct {
typ, regex, secret, value string
param Argument
headers, query, payload *map[string]interface{}
body []byte
ok bool
}{
{"value", "", "", "z", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, true},
{"regex", "^z", "", "z", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, true},
{"payload-hash-sha1", "", "secret", "", Argument{"header", "a"}, &map[string]interface{}{"a": "b17e04cbb22afa8ffbff8796fc1894ed27badd9e"}, nil, nil, []byte(`{"a": "z"}`), true},
// failures
{"value", "", "", "X", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false},
{"value", "", "", "X", Argument{"header", "a"}, &map[string]interface{}{"y": "z"}, nil, nil, []byte{}, false},
{"regex", "^X", "", "", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false},
{"regex", "*", "", "", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false},
{"payload-hash-sha1", "", "secret", "", Argument{"header", "a"}, &map[string]interface{}{"a": ""}, nil, nil, []byte{}, false},
}
func TestMatchRule(t *testing.T) {
for _, tt := range matchRuleTests {
r := MatchRule{tt.typ, tt.regex, tt.secret, tt.value, tt.param}
ok := r.Evaluate(tt.headers, tt.query, tt.payload, &tt.body)
if ok != tt.ok {
t.Errorf("failed to match %#v:\nexpected %#v,\ngot %#v", r, tt.ok, ok)
}
}
}
var andRuleTests = []struct {
desc string // description of the test case
rule AndRule
headers, query, payload *map[string]interface{}
body []byte
ok bool
}{
{
"(a=z, b=y): a=z && b=y",
AndRule{
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a"}}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b"}}},
},
&map[string]interface{}{"a": "z", "b": "y"}, nil, nil, []byte{},
true,
},
{
"(a=z, b=Y): a=z && b=y",
AndRule{
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a"}}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b"}}},
},
&map[string]interface{}{"a": "z", "b": "Y"}, nil, nil, []byte{},
false,
},
// Complex test to cover Rules.Evaluate
{
"(a=z, b=y, c=x, d=w=, e=X, f=X): a=z && (b=y && c=x) && (d=w || e=v) && !f=u",
AndRule{
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a"}}},
{
And: &AndRule{
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b"}}},
{Match: &MatchRule{"value", "", "", "x", Argument{"header", "c"}}},
},
},
{
Or: &OrRule{
{Match: &MatchRule{"value", "", "", "w", Argument{"header", "d"}}},
{Match: &MatchRule{"value", "", "", "v", Argument{"header", "e"}}},
},
},
{
Not: &NotRule{
Match: &MatchRule{"value", "", "", "u", Argument{"header", "f"}},
},
},
},
&map[string]interface{}{"a": "z", "b": "y", "c": "x", "d": "w", "e": "X", "f": "X"}, nil, nil, []byte{},
true,
},
{"empty rule", AndRule{{}}, nil, nil, nil, nil, false},
}
func TestAndRule(t *testing.T) {
for _, tt := range andRuleTests {
ok := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body)
if ok != tt.ok {
t.Errorf("failed to match %#v:\nexpected %#v,\ngot %#v", tt.desc, tt.ok, ok)
}
}
}
var orRuleTests = []struct {
desc string // description of the test case
rule OrRule
headers, query, payload *map[string]interface{}
body []byte
ok bool
}{
{
"(a=z, b=X): a=z || b=y",
OrRule{
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a"}}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b"}}},
},
&map[string]interface{}{"a": "z", "b": "X"}, nil, nil, []byte{},
true,
},
{
"(a=X, b=y): a=z || b=y",
OrRule{
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a"}}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b"}}},
},
&map[string]interface{}{"a": "X", "b": "y"}, nil, nil, []byte{},
true,
},
{
"(a=Z, b=Y): a=z || b=y",
OrRule{
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a"}}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b"}}},
},
&map[string]interface{}{"a": "Z", "b": "Y"}, nil, nil, []byte{},
false,
},
}
func TestOrRule(t *testing.T) {
for _, tt := range orRuleTests {
ok := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body)
if ok != tt.ok {
t.Errorf("%#v:\nexpected %#v,\ngot %#v", tt.desc, tt.ok, ok)
}
}
}
var notRuleTests = []struct {
desc string // description of the test case
rule NotRule
headers, query, payload *map[string]interface{}
body []byte
ok bool
}{
{"(a=z): !a=X", NotRule{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a"}}}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, true},
{"(a=z): !a=z", NotRule{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a"}}}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false},
}
func TestNotRule(t *testing.T) {
for _, tt := range notRuleTests {
ok := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body)
if ok != tt.ok {
t.Errorf("failed to match %#v:\nexpected %#v,\ngot %#v", tt.rule, tt.ok, ok)
}
}
}
+44 -9
View File
@@ -1,18 +1,53 @@
[
{
"id": "webhook",
"command": "/home/adnan/redeploy-go-webhook.sh",
"args": [
"head"
"execute-command": "/home/adnan/redeploy-go-webhook.sh",
"command-working-directory": "/home/adnan/go",
"response-message": "I got the payload!",
"pass-arguments-to-command":
[
{
"source": "payload",
"name": "head_commit.id"
},
{
"source": "payload",
"name": "pusher.name"
},
{
"source": "payload",
"name": "pusher.email"
}
],
"cwd": "/home/adnan/go",
"trigger-rule":
{
"match":
{
"parameter": "ref",
"value": "refs/heads/master"
}
"and":
[
{
"match":
{
"type": "payload-hash-sha1",
"secret": "mysecret",
"parameter":
{
"source": "header",
"name": "X-Hub-Signature"
}
}
},
{
"match":
{
"type": "value",
"value": "refs/heads/master",
"parameter":
{
"source": "payload",
"name": "ref"
}
}
}
]
}
}
]
-203
View File
@@ -1,203 +0,0 @@
package hooks
import (
"encoding/json"
"io/ioutil"
"net/url"
"github.com/adnanh/webhook/helpers"
"github.com/adnanh/webhook/rules"
)
// Hook is a structure that contains command to be executed
// and the current working directory name where that command should be executed
type Hook struct {
ID string `json:"id"`
Command string `json:"command"`
Cwd string `json:"cwd"`
Secret string `json:"secret"`
Args []string `json:"args"`
Rule rules.Rule `json:"trigger-rule"`
}
// Hooks represents structure that contains list of Hook objects
// and the name of file which is correspondingly mapped to it
type Hooks struct {
fileName string
list []Hook
}
// ParseFormArgs gets arguments from the Form payload that should be passed to the command
func (h *Hook) ParseFormArgs(form url.Values) []string {
var args = make([]string, 0)
args = append(args, h.Command)
for i := range h.Args {
if arg := form[h.Args[i]]; len(arg) > 0 {
args = append(args, arg[0])
} else {
args = append(args, "")
}
}
return args
}
// ParseJSONArgs gets arguments from the JSON payload that should be passed to the command
func (h *Hook) ParseJSONArgs(payload interface{}) []string {
var args = make([]string, 0)
args = append(args, h.Command)
for i := range h.Args {
if arg, ok := helpers.ExtractJSONParameter(h.Args[i], payload); ok {
args = append(args, arg)
} else {
args = append(args, "")
}
}
return args
}
// UnmarshalJSON implementation for a single hook
func (h *Hook) UnmarshalJSON(j []byte) error {
m := make(map[string]interface{})
err := json.Unmarshal(j, &m)
if err != nil {
return err
}
if v, ok := m["id"]; ok {
h.ID = v.(string)
}
if v, ok := m["command"]; ok {
h.Command = v.(string)
}
if v, ok := m["cwd"]; ok {
h.Cwd = v.(string)
}
if v, ok := m["secret"]; ok {
h.Secret = v.(string)
}
if v, ok := m["args"]; ok {
h.Args = make([]string, 0)
for i := range v.([]interface{}) {
h.Args = append(h.Args, v.([]interface{})[i].(string))
}
}
if v, ok := m["trigger-rule"]; ok {
rule := v.(map[string]interface{})
if ruleValue, ok := rule["match"]; ok {
ruleString, _ := json.Marshal(ruleValue)
rulePtr := new(rules.MatchRule)
err = json.Unmarshal(ruleString, rulePtr)
if err != nil {
return err
}
h.Rule = *rulePtr
} else if ruleValue, ok := rule["not"]; ok {
ruleString, _ := json.Marshal(ruleValue)
rulePtr := new(rules.NotRule)
err = json.Unmarshal(ruleString, rulePtr)
if err != nil {
return err
}
h.Rule = *rulePtr
} else if ruleValue, ok := rule["and"]; ok {
ruleString, _ := json.Marshal(ruleValue)
rulePtr := new(rules.AndRule)
err = json.Unmarshal(ruleString, rulePtr)
if err != nil {
return err
}
h.Rule = *rulePtr
} else if ruleValue, ok := rule["or"]; ok {
ruleString, _ := json.Marshal(ruleValue)
rulePtr := new(rules.OrRule)
err = json.Unmarshal(ruleString, rulePtr)
if err != nil {
return err
}
h.Rule = *rulePtr
}
}
return nil
}
// New creates an instance of Hooks, tries to unmarshal contents of hookFile
// and returns a pointer to the newly created instance
func New(hookFile string) (*Hooks, error) {
h := &Hooks{fileName: hookFile}
if hookFile == "" {
return h, nil
}
// parse hook file for hooks
file, e := ioutil.ReadFile(hookFile)
if e != nil {
return h, e
}
e = json.Unmarshal(file, &(h.list))
h.SetDefaults()
return h, e
}
// Match looks for the hook with the given id in the list of hooks
// and returns the pointer to the hook if it exists, or nil if it doesn't exist
func (h *Hooks) Match(id string, params interface{}) *Hook {
for i := range h.list {
if h.list[i].ID == id {
if h.list[i].Rule == nil || (h.list[i].Rule != nil && h.list[i].Rule.Evaluate(params)) {
return &h.list[i]
}
}
}
return nil
}
// Count returns number of hooks in the list
func (h *Hooks) Count() int {
return len(h.list)
}
// SetDefaults sets default values that were ommited for hooks in JSON file
func (h *Hooks) SetDefaults() {
for i := range h.list {
if h.list[i].Cwd == "" {
h.list[i].Cwd = "."
}
if h.list[i].Args == nil {
h.list[i].Args = make([]string, 1)
}
}
}
-262
View File
@@ -1,262 +0,0 @@
package rules
import (
"encoding/json"
"github.com/adnanh/webhook/helpers"
)
// Rule interface
type Rule interface {
Evaluate(params interface{}) bool
}
// AndRule type is a structure that contains list of rules (SubRules) that will be evaluated,
// and the AndRule's Evaluate method will evaluate to true if and only if all
// of the SubRules evaluate to true
type AndRule struct {
SubRules []Rule `json:"and"`
}
// OrRule type is a structure that contains list of rules (SubRules) that will be evaluated,
// and the OrRule's Evaluate method will evaluate to true if any of the SubRules
// evaluate to true
type OrRule struct {
SubRules []Rule `json:"or"`
}
// NotRule type is a structure that contains a single rule (SubRule) that will be evaluated,
// and the OrRule's Evaluate method will evaluate to true if any and only if
// the SubRule evaluates to false
type NotRule struct {
SubRule Rule `json:"not"`
}
// MatchRule type is a structure that contains MatchParameter structure
type MatchRule struct {
MatchParameter MatchParameter `json:"match"`
}
// MatchParameter type is a structure that contains Parameter and Value which are used in
// Match
type MatchParameter struct {
Parameter string `json:"parameter"`
Value string `json:"value"`
}
// Evaluate AndRule will return true if and only if all of SubRules evaluate to true
func (r AndRule) Evaluate(params interface{}) bool {
res := true
for _, v := range r.SubRules {
res = res && v.Evaluate(params)
if res == false {
return res
}
}
return res
}
// Evaluate OrRule will return true if any of SubRules evaluate to true
func (r OrRule) Evaluate(params interface{}) bool {
res := false
for _, v := range r.SubRules {
res = res || v.Evaluate(params)
if res == true {
return res
}
}
return res
}
// Evaluate NotRule will return true if and only if SubRule evaluates to false
func (r NotRule) Evaluate(params interface{}) bool {
return !r.SubRule.Evaluate(params)
}
// Evaluate MatchRule will return true if and only if the MatchParameter.Parameter
// named property value in supplied params matches the MatchParameter.Value
func (r MatchRule) Evaluate(params interface{}) bool {
if v, ok := helpers.ExtractJSONParameter(r.MatchParameter.Parameter, params); ok {
return v == r.MatchParameter.Value
}
return false
}
// UnmarshalJSON implementation for the MatchRule type
func (r *MatchRule) UnmarshalJSON(j []byte) error {
err := json.Unmarshal(j, &r.MatchParameter)
return err
}
// UnmarshalJSON implementation for the NotRule type
func (r *NotRule) UnmarshalJSON(j []byte) error {
m := make(map[string]interface{})
err := json.Unmarshal(j, &m)
if ruleValue, ok := m["match"]; ok {
ruleString, _ := json.Marshal(ruleValue)
rulePtr := new(MatchRule)
err = json.Unmarshal(ruleString, &rulePtr.MatchParameter)
if err != nil {
return err
}
r.SubRule = *rulePtr
} else if ruleValue, ok := m["not"]; ok {
ruleString, _ := json.Marshal(ruleValue)
rulePtr := new(NotRule)
err = json.Unmarshal(ruleString, rulePtr)
if err != nil {
return err
}
r.SubRule = *rulePtr
} else if ruleValue, ok := m["and"]; ok {
ruleString, _ := json.Marshal(ruleValue)
rulePtr := new(AndRule)
err = json.Unmarshal(ruleString, rulePtr)
if err != nil {
return err
}
r.SubRule = *rulePtr
} else if ruleValue, ok := m["or"]; ok {
ruleString, _ := json.Marshal(ruleValue)
rulePtr := new(OrRule)
err = json.Unmarshal(ruleString, rulePtr)
if err != nil {
return err
}
r.SubRule = *rulePtr
}
return err
}
// UnmarshalJSON implementation for the AndRule type
func (r *AndRule) UnmarshalJSON(j []byte) error {
rules := new([]interface{})
err := json.Unmarshal(j, &rules)
for _, rulesValue := range *rules {
m := rulesValue.(map[string]interface{})
if ruleValue, ok := m["match"]; ok {
ruleString, _ := json.Marshal(ruleValue)
rulePtr := new(MatchRule)
err = json.Unmarshal(ruleString, &rulePtr.MatchParameter)
if err != nil {
return err
}
r.SubRules = append(r.SubRules, *rulePtr)
} else if ruleValue, ok := m["not"]; ok {
ruleString, _ := json.Marshal(ruleValue)
rulePtr := new(NotRule)
err = json.Unmarshal(ruleString, rulePtr)
if err != nil {
return err
}
r.SubRules = append(r.SubRules, *rulePtr)
} else if ruleValue, ok := m["and"]; ok {
ruleString, _ := json.Marshal(ruleValue)
rulePtr := new(AndRule)
err = json.Unmarshal(ruleString, rulePtr)
if err != nil {
return err
}
r.SubRules = append(r.SubRules, *rulePtr)
} else if ruleValue, ok := m["or"]; ok {
ruleString, _ := json.Marshal(ruleValue)
rulePtr := new(OrRule)
err = json.Unmarshal(ruleString, rulePtr)
if err != nil {
return err
}
r.SubRules = append(r.SubRules, *rulePtr)
}
}
return err
}
// UnmarshalJSON implementation for the OrRule type
func (r *OrRule) UnmarshalJSON(j []byte) error {
rules := new([]interface{})
err := json.Unmarshal(j, &rules)
for _, rulesValue := range *rules {
m := rulesValue.(map[string]interface{})
if ruleValue, ok := m["match"]; ok {
ruleString, _ := json.Marshal(ruleValue)
rulePtr := new(MatchRule)
err = json.Unmarshal(ruleString, &rulePtr.MatchParameter)
if err != nil {
return err
}
r.SubRules = append(r.SubRules, *rulePtr)
} else if ruleValue, ok := m["not"]; ok {
ruleString, _ := json.Marshal(ruleValue)
rulePtr := new(NotRule)
err = json.Unmarshal(ruleString, rulePtr)
if err != nil {
return err
}
r.SubRules = append(r.SubRules, *rulePtr)
} else if ruleValue, ok := m["and"]; ok {
ruleString, _ := json.Marshal(ruleValue)
rulePtr := new(AndRule)
err = json.Unmarshal(ruleString, rulePtr)
if err != nil {
return err
}
r.SubRules = append(r.SubRules, *rulePtr)
} else if ruleValue, ok := m["or"]; ok {
ruleString, _ := json.Marshal(ruleValue)
rulePtr := new(OrRule)
err = json.Unmarshal(ruleString, rulePtr)
if err != nil {
return err
}
r.SubRules = append(r.SubRules, *rulePtr)
}
}
return err
}
+269 -93
View File
@@ -1,3 +1,5 @@
//+build !windows
package main
import (
@@ -5,134 +7,308 @@ import (
"flag"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
"strings"
"time"
"syscall"
"github.com/adnanh/webhook/helpers"
"github.com/adnanh/webhook/hooks"
"github.com/adnanh/webhook/hook"
"github.com/go-martini/martini"
"github.com/codegangsta/negroni"
"github.com/gorilla/mux"
l4g "code.google.com/p/log4go"
fsnotify "gopkg.in/fsnotify.v1"
)
const (
version string = "1.0.4"
version = "2.3.5"
)
var (
webhooks *hooks.Hooks
appStart time.Time
ip = flag.String("ip", "", "ip the webhook server should listen on")
port = flag.Int("port", 9000, "port the webhook server should listen on")
hooksFilename = flag.String("hooks", "hooks.json", "path to the json file containing defined hooks the webhook should serve")
logFilename = flag.String("log", "webhook.log", "path to the log file")
ip = flag.String("ip", "", "ip the webhook should serve hooks on")
port = flag.Int("port", 9000, "port the webhook should serve hooks on")
verbose = flag.Bool("verbose", false, "show verbose output")
noPanic = flag.Bool("nopanic", false, "do not panic if hooks cannot be loaded when webhook is not running in verbose mode")
hotReload = flag.Bool("hotreload", false, "watch hooks file for changes and reload them automatically")
hooksFilePath = flag.String("hooks", "hooks.json", "path to the json file containing defined hooks the webhook should serve")
hooksURLPrefix = flag.String("urlprefix", "hooks", "url prefix to use for served hooks (protocol://yourserver:port/PREFIX/:hook-id)")
secure = flag.Bool("secure", false, "use HTTPS instead of HTTP")
cert = flag.String("cert", "cert.pem", "path to the HTTPS certificate pem file")
key = flag.String("key", "key.pem", "path to the HTTPS certificate private key pem file")
watcher *fsnotify.Watcher
signals chan os.Signal
hooks hook.Hooks
)
func init() {
hooks = hook.Hooks{}
flag.Parse()
fileLogWriter := l4g.NewFileLogWriter(*logFilename, false)
fileLogWriter.SetRotateDaily(false)
log.SetPrefix("[webhook] ")
log.SetFlags(log.Ldate | log.Ltime)
martini.Env = "production"
if !*verbose {
log.SetOutput(ioutil.Discard)
}
l4g.AddFilter("file", l4g.FINE, fileLogWriter)
log.Println("version " + version + " starting")
// set os signal watcher
log.Printf("setting up os signal watcher\n")
signals = make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGUSR1)
go watchForSignals()
// load and parse hooks
log.Printf("attempting to load hooks from %s\n", *hooksFilePath)
err := hooks.LoadFromFile(*hooksFilePath)
if err != nil {
if !*verbose && !*noPanic {
log.SetOutput(os.Stdout)
log.Fatalf("couldn't load any hooks from file! %+v\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to start without the hooks, either use -verbose flag, or -nopanic", err)
}
log.Printf("couldn't load hooks from file! %+v\n", err)
} else {
log.Printf("loaded %d hook(s) from file\n", len(hooks))
for _, hook := range hooks {
log.Printf("\t> %s\n", hook.ID)
}
}
}
func main() {
appStart = time.Now()
var e error
if *hotReload {
// set up file watcher
log.Printf("setting up file watcher for %s\n", *hooksFilePath)
webhooks, e = hooks.New(*hooksFilename)
var err error
if e != nil {
l4g.Warn("Error occurred while loading hooks from %s: %s", *hooksFilename, e)
}
web := martini.Classic()
web.Get("/", rootHandler)
web.Get("/hook/:id", hookHandler)
web.Post("/hook/:id", hookHandler)
l4g.Info("Starting webhook %s with %d hook(s) on %s:%d", version, webhooks.Count(), *ip, *port)
web.RunOnAddr(fmt.Sprintf("%s:%d", *ip, *port))
}
func rootHandler() string {
return fmt.Sprintf("webhook %s running for %s serving %d hook(s)\n", version, time.Since(appStart).String(), webhooks.Count())
}
func jsonHandler(id string, body []byte, signature string, payload interface{}) {
if hook := webhooks.Match(id, payload); hook != nil {
if hook.Secret != "" {
if signature == "" {
l4g.Error("Hook %s got matched and contains the secret, but the request didn't contain any signature.", hook.ID)
return
}
if expectedMAC, ok := helpers.CheckPayloadSignature(body, hook.Secret, signature); !ok {
l4g.Error("Hook %s got matched and contains the secret, but the request contained invalid signature. Expected %s, got %s.", hook.ID, expectedMAC, signature)
return
}
}
cmd := exec.Command(hook.Command)
cmd.Args = hook.ParseJSONArgs(payload)
cmd.Dir = hook.Cwd
out, err := cmd.Output()
l4g.Info("Hook %s triggered successfully! Command output:\n%s\n%+v", hook.ID, out, err)
}
}
func formHandler(id string, formValues url.Values) {
if hook := webhooks.Match(id, helpers.FormValuesToMap(formValues)); hook != nil {
cmd := exec.Command(hook.Command)
cmd.Args = hook.ParseFormArgs(formValues)
cmd.Dir = hook.Cwd
out, err := cmd.Output()
l4g.Info("Hook %s triggered successfully! Command output:\n%s\n%+v", hook.ID, out, err)
}
}
func hookHandler(req *http.Request, params martini.Params) string {
if req.Header.Get("Content-Type") == "application/json" {
defer req.Body.Close()
body, err := ioutil.ReadAll(req.Body)
watcher, err = fsnotify.NewWatcher()
if err != nil {
l4g.Warn("Error occurred while trying to read the request body: %s", err)
log.Fatal("error creating file watcher instance", err)
}
payloadJSON := make(map[string]interface{})
defer watcher.Close()
decoder := json.NewDecoder(strings.NewReader(string(body)))
decoder.UseNumber()
err = decoder.Decode(&payloadJSON)
go watchForFileChange()
err = watcher.Add(*hooksFilePath)
if err != nil {
l4g.Warn("Error occurred while trying to parse the payload as JSON: %s", err)
log.Fatal("error adding hooks file to the watcher", err)
}
}
payloadSignature := ""
l := negroni.NewLogger()
l.Logger = log.New(os.Stdout, "[webhook] ", log.Ldate|log.Ltime)
if strings.Contains(req.Header.Get("User-Agent"), "GitHub-Hookshot") {
if len(req.Header.Get("X-Hub-Signature")) > 5 {
payloadSignature = req.Header.Get("X-Hub-Signature")[5:]
}
}
go jsonHandler(params["id"], body, payloadSignature, payloadJSON)
negroniRecovery := &negroni.Recovery{
Logger: l.Logger,
PrintStack: true,
StackAll: false,
StackSize: 1024 * 8,
}
n := negroni.New(negroniRecovery, l)
router := mux.NewRouter()
var hooksURL string
if *hooksURLPrefix == "" {
hooksURL = "/{id}"
} else {
req.ParseForm()
go formHandler(params["id"], req.Form)
hooksURL = "/" + *hooksURLPrefix + "/{id}"
}
router.HandleFunc(hooksURL, hookHandler)
n.UseHandler(router)
if *secure {
log.Printf("starting secure (https) webhook on %s:%d", *ip, *port)
log.Fatal(http.ListenAndServeTLS(fmt.Sprintf("%s:%d", *ip, *port), *cert, *key, n))
} else {
log.Printf("starting insecure (http) webhook on %s:%d", *ip, *port)
log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%d", *ip, *port), n))
}
return "Got it, thanks. :-)"
}
func hookHandler(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
matchedHooks := hooks.MatchAll(id)
if matchedHooks != nil {
log.Printf("%s got matched (%d time(s))\n", id, len(matchedHooks))
body, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Printf("error reading the request body. %+v\n", err)
}
// parse headers
headers := valuesToMap(r.Header)
// parse query variables
query := valuesToMap(r.URL.Query())
// parse body
var payload map[string]interface{}
contentType := r.Header.Get("Content-Type")
if strings.Contains(contentType, "json") {
decoder := json.NewDecoder(strings.NewReader(string(body)))
decoder.UseNumber()
err := decoder.Decode(&payload)
if err != nil {
log.Printf("error parsing JSON payload %+v\n", err)
}
} else if strings.Contains(contentType, "form") {
fd, err := url.ParseQuery(string(body))
if err != nil {
log.Printf("error parsing form payload %+v\n", err)
} else {
payload = valuesToMap(fd)
}
}
// handle hook
for _, h := range matchedHooks {
h.ParseJSONParameters(&headers, &query, &payload)
if h.TriggerRule == nil || h.TriggerRule != nil && h.TriggerRule.Evaluate(&headers, &query, &payload, &body) {
log.Printf("%s hook triggered successfully\n", h.ID)
if h.CaptureCommandOutput {
response := handleHook(h, &headers, &query, &payload, &body)
fmt.Fprintf(w, response)
} else {
go handleHook(h, &headers, &query, &payload, &body)
fmt.Fprintf(w, h.ResponseMessage)
}
return
}
}
// if none of the hooks got triggered
log.Printf("%s got matched (%d time(s)), but didn't get triggered because the trigger rules were not satisfied\n", matchedHooks[0].ID, len(matchedHooks))
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "Hook rules were not satisfied.")
} else {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "Hook not found.")
}
}
func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, body *[]byte) string {
cmd := exec.Command(h.ExecuteCommand)
cmd.Args = h.ExtractCommandArguments(headers, query, payload)
cmd.Dir = h.CommandWorkingDirectory
log.Printf("executing %s (%s) with arguments %s using %s as cwd\n", h.ExecuteCommand, cmd.Path, cmd.Args, cmd.Dir)
out, err := cmd.CombinedOutput()
log.Printf("command output: %s\n", out)
var errorResponse string
if err != nil {
log.Printf("error occurred: %+v\n", err)
errorResponse = fmt.Sprintf("%+v", err)
}
log.Printf("finished handling %s\n", h.ID)
var response []byte
response, err = json.Marshal(&hook.CommandStatusResponse{ResponseMessage: h.ResponseMessage, Output: string(out), Error: errorResponse})
if err != nil {
log.Printf("error marshalling response: %+v", err)
return h.ResponseMessage
}
return string(response)
}
func reloadHooks() {
newHooks := hook.Hooks{}
// parse and swap
log.Printf("attempting to reload hooks from %s\n", *hooksFilePath)
err := newHooks.LoadFromFile(*hooksFilePath)
if err != nil {
log.Printf("couldn't load hooks from file! %+v\n", err)
} else {
log.Printf("loaded %d hook(s) from file\n", len(hooks))
for _, hook := range hooks {
log.Printf("\t> %s\n", hook.ID)
}
hooks = newHooks
}
}
func watchForFileChange() {
for {
select {
case event := <-(*watcher).Events:
if event.Op&fsnotify.Write == fsnotify.Write {
log.Println("hooks file modified")
reloadHooks()
}
case err := <-(*watcher).Errors:
log.Println("watcher error:", err)
}
}
}
func watchForSignals() {
log.Println("os signal watcher ready")
for {
sig := <-signals
if sig == syscall.SIGUSR1 {
log.Println("caught USR1 signal")
reloadHooks()
} else {
log.Printf("caught unhandled signal %+v\n", sig)
}
}
}
// valuesToMap converts map[string][]string to a map[string]string object
func valuesToMap(values map[string][]string) map[string]interface{} {
ret := make(map[string]interface{})
for key, value := range values {
if len(value) > 0 {
ret[key] = value[0]
}
}
return ret
}
+287
View File
@@ -0,0 +1,287 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"strings"
"github.com/adnanh/webhook/hook"
"github.com/codegangsta/negroni"
"github.com/gorilla/mux"
fsnotify "gopkg.in/fsnotify.v1"
)
const (
version = "2.3.5"
)
var (
ip = flag.String("ip", "", "ip the webhook should serve hooks on")
port = flag.Int("port", 9000, "port the webhook should serve hooks on")
verbose = flag.Bool("verbose", false, "show verbose output")
noPanic = flag.Bool("nopanic", false, "do not panic if hooks cannot be loaded when webhook is not running in verbose mode")
hotReload = flag.Bool("hotreload", false, "watch hooks file for changes and reload them automatically")
hooksFilePath = flag.String("hooks", "hooks.json", "path to the json file containing defined hooks the webhook should serve")
hooksURLPrefix = flag.String("urlprefix", "hooks", "url prefix to use for served hooks (protocol://yourserver:port/PREFIX/:hook-id)")
secure = flag.Bool("secure", false, "use HTTPS instead of HTTP")
cert = flag.String("cert", "cert.pem", "path to the HTTPS certificate pem file")
key = flag.String("key", "key.pem", "path to the HTTPS certificate private key pem file")
watcher *fsnotify.Watcher
signals chan os.Signal
hooks hook.Hooks
)
func init() {
hooks = hook.Hooks{}
flag.Parse()
log.SetPrefix("[webhook] ")
log.SetFlags(log.Ldate | log.Ltime)
if !*verbose {
log.SetOutput(ioutil.Discard)
}
log.Println("version " + version + " starting")
// load and parse hooks
log.Printf("attempting to load hooks from %s\n", *hooksFilePath)
err := hooks.LoadFromFile(*hooksFilePath)
if err != nil {
if !*verbose && !*noPanic {
log.SetOutput(os.Stdout)
log.Fatalf("couldn't load any hooks from file! %+v\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to start without the hooks, either use -verbose flag, or -nopanic", err)
}
log.Printf("couldn't load hooks from file! %+v\n", err)
} else {
log.Printf("loaded %d hook(s) from file\n", len(hooks))
for _, hook := range hooks {
log.Printf("\t> %s\n", hook.ID)
}
}
}
func main() {
if *hotReload {
// set up file watcher
log.Printf("setting up file watcher for %s\n", *hooksFilePath)
var err error
watcher, err = fsnotify.NewWatcher()
if err != nil {
log.Fatal("error creating file watcher instance", err)
}
defer watcher.Close()
go watchForFileChange()
err = watcher.Add(*hooksFilePath)
if err != nil {
log.Fatal("error adding hooks file to the watcher", err)
}
}
l := negroni.NewLogger()
l.Logger = log.New(os.Stdout, "[webhook] ", log.Ldate|log.Ltime)
negroniRecovery := &negroni.Recovery{
Logger: l.Logger,
PrintStack: true,
StackAll: false,
StackSize: 1024 * 8,
}
n := negroni.New(negroniRecovery, l)
router := mux.NewRouter()
var hooksURL string
if *hooksURLPrefix == "" {
hooksURL = "/{id}"
} else {
hooksURL = "/" + *hooksURLPrefix + "/{id}"
}
router.HandleFunc(hooksURL, hookHandler)
n.UseHandler(router)
if *secure {
log.Printf("starting secure (https) webhook on %s:%d", *ip, *port)
log.Fatal(http.ListenAndServeTLS(fmt.Sprintf("%s:%d", *ip, *port), *cert, *key, n))
} else {
log.Printf("starting insecure (http) webhook on %s:%d", *ip, *port)
log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%d", *ip, *port), n))
}
}
func hookHandler(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
matchedHooks := hooks.MatchAll(id)
if matchedHooks != nil {
log.Printf("%s got matched (%d time(s))\n", id, len(matchedHooks))
body, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Printf("error reading the request body. %+v\n", err)
}
// parse headers
headers := valuesToMap(r.Header)
// parse query variables
query := valuesToMap(r.URL.Query())
// parse body
var payload map[string]interface{}
contentType := r.Header.Get("Content-Type")
if strings.Contains(contentType, "json") {
decoder := json.NewDecoder(strings.NewReader(string(body)))
decoder.UseNumber()
err := decoder.Decode(&payload)
if err != nil {
log.Printf("error parsing JSON payload %+v\n", err)
}
} else if strings.Contains(contentType, "form") {
fd, err := url.ParseQuery(string(body))
if err != nil {
log.Printf("error parsing form payload %+v\n", err)
} else {
payload = valuesToMap(fd)
}
}
// handle hook
for _, h := range matchedHooks {
h.ParseJSONParameters(&headers, &query, &payload)
if h.TriggerRule == nil || h.TriggerRule != nil && h.TriggerRule.Evaluate(&headers, &query, &payload, &body) {
log.Printf("%s hook triggered successfully\n", h.ID)
if h.CaptureCommandOutput {
response := handleHook(h, &headers, &query, &payload, &body)
fmt.Fprintf(w, response)
} else {
go handleHook(h, &headers, &query, &payload, &body)
fmt.Fprintf(w, h.ResponseMessage)
}
return
}
}
// if none of the hooks got triggered
log.Printf("%s got matched (%d time(s)), but didn't get triggered because the trigger rules were not satisfied\n", matchedHooks[0].ID, len(matchedHooks))
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "Hook rules were not satisfied.")
} else {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "Hook not found.")
}
}
func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, body *[]byte) string {
cmd := exec.Command(h.ExecuteCommand)
cmd.Args = h.ExtractCommandArguments(headers, query, payload)
cmd.Dir = h.CommandWorkingDirectory
log.Printf("executing %s (%s) with arguments %s using %s as cwd\n", h.ExecuteCommand, cmd.Path, cmd.Args, cmd.Dir)
out, err := cmd.CombinedOutput()
log.Printf("command output: %s\n", out)
var errorResponse string
if err != nil {
log.Printf("error occurred: %+v\n", err)
errorResponse = fmt.Sprintf("%+v", err)
}
log.Printf("finished handling %s\n", h.ID)
var response []byte
response, err = json.Marshal(&hook.CommandStatusResponse{ResponseMessage: h.ResponseMessage, Output: string(out), Error: errorResponse})
if err != nil {
log.Printf("error marshalling response: %+v", err)
return h.ResponseMessage
}
return string(response)
}
func reloadHooks() {
newHooks := hook.Hooks{}
// parse and swap
log.Printf("attempting to reload hooks from %s\n", *hooksFilePath)
err := newHooks.LoadFromFile(*hooksFilePath)
if err != nil {
log.Printf("couldn't load hooks from file! %+v\n", err)
} else {
log.Printf("loaded %d hook(s) from file\n", len(hooks))
for _, hook := range hooks {
log.Printf("\t> %s\n", hook.ID)
}
hooks = newHooks
}
}
func watchForFileChange() {
for {
select {
case event := <-(*watcher).Events:
if event.Op&fsnotify.Write == fsnotify.Write {
log.Println("hooks file modified")
reloadHooks()
}
case err := <-(*watcher).Errors:
log.Println("watcher error:", err)
}
}
}
// valuesToMap converts map[string][]string to a map[string]string object
func valuesToMap(values map[string][]string) map[string]interface{} {
ret := make(map[string]interface{})
for key, value := range values {
if len(value) > 0 {
ret[key] = value[0]
}
}
return ret
}