Compare commits

...

29 Commits

Author SHA1 Message Date
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
4 changed files with 576 additions and 123 deletions
-77
View File
@@ -1,77 +0,0 @@
package helpers
import (
"crypto/hmac"
"crypto/sha1"
"encoding/hex"
"fmt"
"reflect"
"strconv"
"strings"
)
// 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))
}
// 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
}
// ExtractParameter extracts value from interface{} based on the passed string
func ExtractParameter(s string, params interface{}) (string, bool) {
if params == nil {
return "", false
}
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 ExtractParameter(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 ExtractParameter(p[1], pValue)
}
} else {
if pValue, ok := params.(map[string]interface{})[p[0]]; ok {
return fmt.Sprintf("%v", pValue), true
}
}
return "", false
}
+154 -5
View File
@@ -1,12 +1,17 @@
package hook
import (
"crypto/hmac"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"reflect"
"regexp"
"github.com/adnanh/webhook/helpers"
"strconv"
"strings"
)
// Constants used to specify the parameter source
@@ -16,6 +21,114 @@ const (
SourcePayload string = "payload"
)
// 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 {
@@ -38,7 +151,7 @@ func (ha *Argument) Get(headers, query, payload *map[string]interface{}) (string
}
if source != nil {
return helpers.ExtractParameter(ha.Name, *source)
return ExtractParameterAsString(ha.Name, *source)
}
return "", false
@@ -49,10 +162,46 @@ type Hook struct {
ID string `json:"id"`
ExecuteCommand string `json:"execute-command"`
CommandWorkingDirectory string `json:"command-working-directory"`
ResponseMessage string `json:"response-message"`
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
}
ReplaceParameter(h.JSONStringParameters[i].Name, source, newArg)
}
} 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 {
@@ -168,7 +317,7 @@ 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 !r.Evaluate(headers, query, payload, body)
return !Rules(r).Evaluate(headers, query, payload, body)
}
// MatchRule will evaluate to true based on the type
@@ -200,7 +349,7 @@ func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, bod
}
return ok
case MatchHashSHA1:
expected, ok := helpers.CheckPayloadSignature(*body, r.Secret, arg)
expected, ok := CheckPayloadSignature(*body, r.Secret, arg)
if !ok {
log.Printf("payload signature mismatch, expected %s got %s", expected, arg)
}
+327
View File
@@ -0,0 +1,327 @@
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},
// 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
}
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)
}
}
}
+95 -41
View File
@@ -10,9 +10,10 @@ import (
"net/url"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"github.com/adnanh/webhook/helpers"
"github.com/adnanh/webhook/hook"
"github.com/codegangsta/negroni"
@@ -22,20 +23,22 @@ import (
)
const (
version = "2.2.0"
version = "2.3.2"
)
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")
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")
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")
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")
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
)
@@ -54,6 +57,14 @@ func init() {
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.Signal(0xa))
go watchForSignals()
// load and parse hooks
log.Printf("attempting to load hooks from %s\n", *hooksFilePath)
@@ -92,21 +103,29 @@ func main() {
}
}
l := log.New(os.Stdout, "[webhook] ", log.Ldate|log.Ltime)
negroniLogger := &negroni.Logger{l}
l := negroni.NewLogger()
l.Logger = log.New(os.Stdout, "[webhook] ", log.Ldate|log.Ltime)
negroniRecovery := &negroni.Recovery{
Logger: l,
Logger: l.Logger,
PrintStack: true,
StackAll: false,
StackSize: 1024 * 8,
}
n := negroni.New(negroniRecovery, negroniLogger)
n := negroni.New(negroniRecovery, l)
router := mux.NewRouter()
router.HandleFunc("/hooks/{id}", hookHandler)
var hooksURL string
if *hooksURLPrefix == "" {
hooksURL = "/{id}"
} else {
hooksURL = "/" + *hooksURLPrefix + "/{id}"
}
router.HandleFunc(hooksURL, hookHandler)
n.UseHandler(router)
@@ -134,17 +153,17 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
}
// parse headers
headers := helpers.ValuesToMap(r.Header)
headers := valuesToMap(r.Header)
// parse query variables
query := helpers.ValuesToMap(r.URL.Query())
query := valuesToMap(r.URL.Query())
// parse body
var payload map[string]interface{}
contentType := r.Header.Get("Content-Type")
if contentType == "application/json" {
if strings.HasPrefix(contentType, "application/json") {
decoder := json.NewDecoder(strings.NewReader(string(body)))
decoder.UseNumber()
@@ -153,21 +172,24 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("error parsing JSON payload %+v\n", err)
}
} else if contentType == "application/x-www-form-urlencoded" {
} else if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") {
fd, err := url.ParseQuery(string(body))
if err != nil {
log.Printf("error parsing form payload %+v\n", err)
} else {
payload = helpers.ValuesToMap(fd)
payload = valuesToMap(fd)
}
}
hook.ParseJSONParameters(&headers, &query, &payload)
// handle hook
go handleHook(hook, &headers, &query, &payload, &body)
// say thanks
fmt.Fprintf(w, "Thanks.")
// send the hook defined response message
fmt.Fprintf(w, hook.ResponseMessage)
} else {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "Hook not found.")
}
}
@@ -195,6 +217,27 @@ func handleHook(hook *hook.Hook, headers, query, payload *map[string]interface{}
}
}
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 {
@@ -202,27 +245,38 @@ func watchForFileChange() {
if event.Op&fsnotify.Write == fsnotify.Write {
log.Println("hooks file modified")
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
}
reloadHooks()
}
case err := <-(*watcher).Errors:
log.Println("watcher error:", err)
}
}
}
func watchForSignals() {
log.Println("os signal watcher ready")
for {
sig := <-signals
if sig == syscall.Signal(0xa) {
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
}