Compare commits

...

19 Commits

Author SHA1 Message Date
Adnan Hajdarević 8803239e6d Merge pull request #115 from adnanh/development
Support loading hooks from multiple files
2017-02-11 23:01:01 +01:00
Adnan Hajdarevic c8a83349d2 remove hooks after the underlying file is removed 2017-02-11 22:58:49 +01:00
Adnan Hajdarevic 1da40d4634 Merge branch 'master' into development 2017-02-11 12:15:04 +01:00
Adnan Hajdarević c51971fd37 Bump version 2017-02-10 19:19:53 +01:00
Adnan Hajdarević 93e5fe7712 Merge pull request #113 from DG-i/master
make http return code for mismatch rules configurable
2017-02-10 19:19:33 +01:00
Mathias Merscher 058f820cbd rename trigger rule http response code config option
Signed-off-by: Mathias Merscher <Mathias.Merscher@dg-i.net>
2017-02-10 18:42:02 +01:00
Mathias Merscher ddb1f2441a make http return code for mismatched rules configurable
Signed-off-by: Mathias Merscher <Mathias.Merscher@dg-i.net>
2017-02-10 12:32:11 +01:00
Adnan Hajdarević 8226d5e50f Update README.md 2017-02-09 05:22:59 +01:00
Adnan Hajdarević e149c99724 Merge pull request #111 from moorereason/fix106tests
Fix failing header tests
2016-12-22 20:44:57 +01:00
Cameron Moore 6ea2d68b44 Fix failing header tests
Now that we use textproto.CanonicalMIMEHeaderKey, all header field test
data needs to be title-cased.
2016-12-22 10:23:55 -06:00
Adnan Hajdarevic 79453b7a22 Use hooks.json as default file if no hooks file have been specified 2016-12-05 21:28:38 +01:00
Adnan Hajdarevic 8207c6cf12 Started work on multi file hooks loading 2016-12-05 21:22:34 +01:00
Adnan Hajdarević 5789362186 Merge pull request #108 from adnanh/development
Update README.md
2016-11-22 15:23:23 +01:00
Adnan Hajdarević 07f166616c Update README.md 2016-11-22 15:23:09 +01:00
Adnan Hajdarević b66216675a Merge pull request #107 from adnanh/development
2.6.0
2016-11-01 20:11:07 +01:00
Adnan Hajdarevic ecbcf11153 fix #106 2016-11-01 20:09:41 +01:00
Adnan Hajdarević 7d525cf317 Merge pull request #105 from adnanh/development
Fixes #103
2016-10-28 16:07:07 +02:00
Adnan Hajdarević e83d7029ff Merge pull request #104 from adnanh/master
Backmerge
2016-10-28 16:06:33 +02:00
Adnan Hajdarević 36c2c692d6 Add instructions for debian 2016-10-28 16:05:39 +02:00
7 changed files with 307 additions and 125 deletions
+8 -2
View File
@@ -1,5 +1,4 @@
[![ghit.me](https://ghit.me/badge.svg?repo=adnanh/webhook)](https://ghit.me/repo/adnanh/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) [![ghit.me](https://ghit.me/badge.svg?repo=adnanh/webhook)](https://ghit.me/repo/adnanh/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) [Donate via PayPal](https://paypal.me/hookdoo) | [Patreon page](https://www.patreon.com/webhook)
# What is webhook? # 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. [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.
@@ -19,12 +18,19 @@ Everything else is the responsibility of the command's author.
--- ---
# Getting started # Getting started
## Installation
### Building from source
To get started, first make sure you've properly set up your [Golang](http://golang.org/doc/install) environment and then run the To get started, first make sure you've properly set up your [Golang](http://golang.org/doc/install) environment and then run the
```bash ```bash
$ go get github.com/adnanh/webhook $ go get github.com/adnanh/webhook
``` ```
to get the latest version of the [webhook](https://github.com/adnanh/webhook/). to get the latest version of the [webhook](https://github.com/adnanh/webhook/).
### Using package manager
#### Debian "sid"
If you are using unstable version of Debian linux ("sid"), you can install webhook using `apt-get install webhook` which will install community packaged version (thanks [@freeekanayaka](https://github.com/freeekanayaka)) from https://packages.debian.org/sid/webhook
## Configuration
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. 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.
Let's define a simple hook named `redeploy-webhook` that will run a redeploy script located in `/var/scripts/redeploy.sh`. Let's define a simple hook named `redeploy-webhook` that will run a redeploy script located in `/var/scripts/redeploy.sh`.
+71 -14
View File
@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/textproto"
"reflect" "reflect"
"regexp" "regexp"
"strconv" "strconv"
@@ -18,6 +19,7 @@ import (
const ( const (
SourceHeader string = "header" SourceHeader string = "header"
SourceQuery string = "url" SourceQuery string = "url"
SourceQueryAlias string = "query"
SourcePayload string = "payload" SourcePayload string = "payload"
SourceString string = "string" SourceString string = "string"
SourceEntirePayload string = "entire-payload" SourceEntirePayload string = "entire-payload"
@@ -205,11 +207,13 @@ type Argument struct {
// based on the Argument's source // based on the Argument's source
func (ha *Argument) Get(headers, query, payload *map[string]interface{}) (string, bool) { func (ha *Argument) Get(headers, query, payload *map[string]interface{}) (string, bool) {
var source *map[string]interface{} var source *map[string]interface{}
key := ha.Name
switch ha.Source { switch ha.Source {
case SourceHeader: case SourceHeader:
source = headers source = headers
case SourceQuery: key = textproto.CanonicalMIMEHeaderKey(ha.Name)
case SourceQuery, SourceQueryAlias:
source = query source = query
case SourcePayload: case SourcePayload:
source = payload source = payload
@@ -242,7 +246,7 @@ func (ha *Argument) Get(headers, query, payload *map[string]interface{}) (string
} }
if source != nil { if source != nil {
return ExtractParameterAsString(ha.Name, *source) return ExtractParameterAsString(key, *source)
} }
return "", false return "", false
@@ -269,7 +273,7 @@ func (h *ResponseHeaders) String() string {
result[idx] = fmt.Sprintf("%s=%s", responseHeader.Name, responseHeader.Value) result[idx] = fmt.Sprintf("%s=%s", responseHeader.Name, responseHeader.Value)
} }
return fmt.Sprint(strings.Join(result, ", ")) return strings.Join(result, ", ")
} }
// Set method appends new Header object from header=value notation // Set method appends new Header object from header=value notation
@@ -284,6 +288,23 @@ func (h *ResponseHeaders) Set(value string) error {
return nil return nil
} }
// HooksFiles is a slice of String
type HooksFiles []string
func (h *HooksFiles) String() string {
if len(*h) == 0 {
return "hooks.json"
}
return strings.Join(*h, ", ")
}
// Set method appends new string
func (h *HooksFiles) Set(value string) error {
*h = append(*h, value)
return nil
}
// Hook type is a structure containing details for a single hook // Hook type is a structure containing details for a single hook
type Hook struct { type Hook struct {
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
@@ -296,11 +317,14 @@ type Hook struct {
PassArgumentsToCommand []Argument `json:"pass-arguments-to-command,omitempty"` PassArgumentsToCommand []Argument `json:"pass-arguments-to-command,omitempty"`
JSONStringParameters []Argument `json:"parse-parameters-as-json,omitempty"` JSONStringParameters []Argument `json:"parse-parameters-as-json,omitempty"`
TriggerRule *Rules `json:"trigger-rule,omitempty"` TriggerRule *Rules `json:"trigger-rule,omitempty"`
TriggerRuleMismatchHttpResponseCode int `json:"trigger-rule-mismatch-http-response-code,omitempty"`
} }
// ParseJSONParameters decodes specified arguments to JSON objects and replaces the // ParseJSONParameters decodes specified arguments to JSON objects and replaces the
// string with the newly created object // string with the newly created object
func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface{}) error { func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface{}) []error {
var errors = make([]error, 0)
for i := range h.JSONStringParameters { for i := range h.JSONStringParameters {
if arg, ok := h.JSONStringParameters[i].Get(headers, query, payload); ok { if arg, ok := h.JSONStringParameters[i].Get(headers, query, payload); ok {
var newArg map[string]interface{} var newArg map[string]interface{}
@@ -311,7 +335,8 @@ func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface
err := decoder.Decode(&newArg) err := decoder.Decode(&newArg)
if err != nil { if err != nil {
return &ParseError{err} errors = append(errors, &ParseError{err})
continue
} }
var source *map[string]interface{} var source *map[string]interface{}
@@ -321,27 +346,38 @@ func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface
source = headers source = headers
case SourcePayload: case SourcePayload:
source = payload source = payload
case SourceQuery: case SourceQuery, SourceQueryAlias:
source = query source = query
} }
if source != nil { if source != nil {
ReplaceParameter(h.JSONStringParameters[i].Name, source, newArg) key := h.JSONStringParameters[i].Name
if h.JSONStringParameters[i].Source == SourceHeader {
key = textproto.CanonicalMIMEHeaderKey(h.JSONStringParameters[i].Name)
}
ReplaceParameter(key, source, newArg)
} else { } else {
return &SourceError{h.JSONStringParameters[i]} errors = append(errors, &SourceError{h.JSONStringParameters[i]})
} }
} else { } else {
return &ArgumentError{h.JSONStringParameters[i]} errors = append(errors, &ArgumentError{h.JSONStringParameters[i]})
} }
} }
if len(errors) > 0 {
return errors
}
return nil return nil
} }
// ExtractCommandArguments creates a list of arguments, based on the // ExtractCommandArguments creates a list of arguments, based on the
// PassArgumentsToCommand property that is ready to be used with exec.Command() // PassArgumentsToCommand property that is ready to be used with exec.Command()
func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]interface{}) ([]string, error) { func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]interface{}) ([]string, []error) {
var args = make([]string, 0) var args = make([]string, 0)
var errors = make([]error, 0)
args = append(args, h.ExecuteCommand) args = append(args, h.ExecuteCommand)
@@ -350,19 +386,23 @@ func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]inter
args = append(args, arg) args = append(args, arg)
} else { } else {
args = append(args, "") args = append(args, "")
return args, &ArgumentError{h.PassArgumentsToCommand[i]} errors = append(errors, &ArgumentError{h.PassArgumentsToCommand[i]})
} }
} }
if len(errors) > 0 {
return args, errors
}
return args, nil return args, nil
} }
// ExtractCommandArgumentsForEnv creates a list of arguments in key=value // ExtractCommandArgumentsForEnv creates a list of arguments in key=value
// format, based on the PassEnvironmentToCommand property that is ready to be used // format, based on the PassEnvironmentToCommand property that is ready to be used
// with exec.Command(). // with exec.Command().
func (h *Hook) ExtractCommandArgumentsForEnv(headers, query, payload *map[string]interface{}) ([]string, error) { func (h *Hook) ExtractCommandArgumentsForEnv(headers, query, payload *map[string]interface{}) ([]string, []error) {
var args = make([]string, 0) var args = make([]string, 0)
var errors = make([]error, 0)
for i := range h.PassEnvironmentToCommand { for i := range h.PassEnvironmentToCommand {
if arg, ok := h.PassEnvironmentToCommand[i].Get(headers, query, payload); ok { if arg, ok := h.PassEnvironmentToCommand[i].Get(headers, query, payload); ok {
if h.PassEnvironmentToCommand[i].EnvName != "" { if h.PassEnvironmentToCommand[i].EnvName != "" {
@@ -373,10 +413,14 @@ func (h *Hook) ExtractCommandArgumentsForEnv(headers, query, payload *map[string
args = append(args, EnvNamespace+h.PassEnvironmentToCommand[i].Name+"="+arg) args = append(args, EnvNamespace+h.PassEnvironmentToCommand[i].Name+"="+arg)
} }
} else { } else {
return args, &ArgumentError{h.PassEnvironmentToCommand[i]} errors = append(errors, &ArgumentError{h.PassEnvironmentToCommand[i]})
} }
} }
if len(errors) > 0 {
return args, errors
}
return args, nil return args, nil
} }
@@ -400,6 +444,19 @@ func (h *Hooks) LoadFromFile(path string) error {
return e return e
} }
// Append appends hooks unless the new hooks contain a hook with an ID that already exists
func (h *Hooks) Append(other *Hooks) error {
for _, hook := range *other {
if h.Match(hook.ID) != nil {
return fmt.Errorf("hook with ID %s is already defined", hook.ID)
}
*h = append(*h, hook)
}
return nil
}
// Match iterates through Hooks and returns first one that matches the given ID, // Match iterates through Hooks and returns first one that matches the given ID,
// if no hook matches the given ID, nil is returned // if no hook matches the given ID, nil is returned
func (h *Hooks) Match(id string) *Hook { func (h *Hooks) Match(id string) *Hook {
+31 -31
View File
@@ -72,15 +72,15 @@ var argumentGetTests = []struct {
value string value string
ok bool ok bool
}{ }{
{"header", "a", &map[string]interface{}{"a": "z"}, nil, nil, "z", true}, {"header", "a", &map[string]interface{}{"A": "z"}, nil, nil, "z", true},
{"url", "a", nil, &map[string]interface{}{"a": "z"}, 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}, {"payload", "a", nil, nil, &map[string]interface{}{"a": "z"}, "z", true},
{"string", "a", nil, nil, &map[string]interface{}{"a": "z"}, "a", true}, {"string", "a", nil, nil, &map[string]interface{}{"a": "z"}, "a", true},
// failures // failures
{"header", "a", nil, &map[string]interface{}{"a": "z"}, &map[string]interface{}{"a": "z"}, "", false}, // nil headers {"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 {"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 {"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 {"foo", "a", &map[string]interface{}{"A": "z"}, nil, nil, "", false}, // invalid source
} }
func TestArgumentGet(t *testing.T) { func TestArgumentGet(t *testing.T) {
@@ -99,14 +99,14 @@ var hookParseJSONParametersTests = []struct {
rheaders, rquery, rpayload *map[string]interface{} rheaders, rquery, rpayload *map[string]interface{}
ok bool ok bool
}{ }{
{[]Argument{Argument{"header", "a", ""}}, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, nil, nil, true}, {[]Argument{Argument{"header", "a", ""}}, &map[string]interface{}{"A": `{"b": "y"}`}, nil, nil, &map[string]interface{}{"A": map[string]interface{}{"b": "y"}}, nil, nil, true},
{[]Argument{Argument{"url", "a", ""}}, nil, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, nil, true}, {[]Argument{Argument{"url", "a", ""}}, nil, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, nil, true},
{[]Argument{Argument{"payload", "a", ""}}, nil, nil, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, true}, {[]Argument{Argument{"payload", "a", ""}}, nil, nil, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, true},
{[]Argument{Argument{"header", "z", ""}}, &map[string]interface{}{"z": `{}`}, nil, nil, &map[string]interface{}{"z": map[string]interface{}{}}, nil, nil, true}, {[]Argument{Argument{"header", "z", ""}}, &map[string]interface{}{"Z": `{}`}, nil, nil, &map[string]interface{}{"Z": map[string]interface{}{}}, nil, nil, true},
// failures // failures
{[]Argument{Argument{"header", "z", ""}}, &map[string]interface{}{"z": ``}, nil, nil, &map[string]interface{}{"z": ``}, nil, nil, false}, // empty string {[]Argument{Argument{"header", "z", ""}}, &map[string]interface{}{"Z": ``}, nil, nil, &map[string]interface{}{"Z": ``}, nil, nil, false}, // empty string
{[]Argument{Argument{"header", "y", ""}}, &map[string]interface{}{"X": `{}`}, nil, nil, &map[string]interface{}{"X": `{}`}, nil, nil, false}, // missing parameter {[]Argument{Argument{"header", "y", ""}}, &map[string]interface{}{"X": `{}`}, nil, nil, &map[string]interface{}{"X": `{}`}, nil, nil, false}, // missing parameter
{[]Argument{Argument{"string", "z", ""}}, &map[string]interface{}{"z": ``}, nil, nil, &map[string]interface{}{"z": ``}, nil, nil, false}, // invalid argument source {[]Argument{Argument{"string", "z", ""}}, &map[string]interface{}{"Z": ``}, nil, nil, &map[string]interface{}{"Z": ``}, nil, nil, false}, // invalid argument source
} }
func TestHookParseJSONParameters(t *testing.T) { func TestHookParseJSONParameters(t *testing.T) {
@@ -126,9 +126,9 @@ var hookExtractCommandArgumentsTests = []struct {
value []string value []string
ok bool ok bool
}{ }{
{"test", []Argument{Argument{"header", "a", ""}}, &map[string]interface{}{"a": "z"}, nil, nil, []string{"test", "z"}, true}, {"test", []Argument{Argument{"header", "a", ""}}, &map[string]interface{}{"A": "z"}, nil, nil, []string{"test", "z"}, true},
// failures // failures
{"fail", []Argument{Argument{"payload", "a", ""}}, &map[string]interface{}{"a": "z"}, nil, nil, []string{"fail", ""}, false}, {"fail", []Argument{Argument{"payload", "a", ""}}, &map[string]interface{}{"A": "z"}, nil, nil, []string{"fail", ""}, false},
} }
func TestHookExtractCommandArguments(t *testing.T) { func TestHookExtractCommandArguments(t *testing.T) {
@@ -171,14 +171,14 @@ var hookExtractCommandArgumentsForEnvTests = []struct {
{ {
"test", "test",
[]Argument{Argument{"header", "a", ""}}, []Argument{Argument{"header", "a", ""}},
&map[string]interface{}{"a": "z"}, nil, nil, &map[string]interface{}{"A": "z"}, nil, nil,
[]string{"HOOK_a=z"}, []string{"HOOK_a=z"},
true, true,
}, },
{ {
"test", "test",
[]Argument{Argument{"header", "a", "MYKEY"}}, []Argument{Argument{"header", "a", "MYKEY"}},
&map[string]interface{}{"a": "z"}, nil, nil, &map[string]interface{}{"A": "z"}, nil, nil,
[]string{"MYKEY=z"}, []string{"MYKEY=z"},
true, true,
}, },
@@ -186,7 +186,7 @@ var hookExtractCommandArgumentsForEnvTests = []struct {
{ {
"fail", "fail",
[]Argument{Argument{"payload", "a", ""}}, []Argument{Argument{"payload", "a", ""}},
&map[string]interface{}{"a": "z"}, nil, nil, &map[string]interface{}{"A": "z"}, nil, nil,
[]string{}, []string{},
false, false,
}, },
@@ -248,16 +248,16 @@ var matchRuleTests = []struct {
ok bool ok bool
err bool err bool
}{ }{
{"value", "", "", "z", Argument{"header", "a", ""}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, true, false}, {"value", "", "", "z", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, true, false},
{"regex", "^z", "", "z", Argument{"header", "a", ""}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, true, false}, {"regex", "^z", "", "z", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, true, false},
{"payload-hash-sha1", "", "secret", "", Argument{"header", "a", ""}, &map[string]interface{}{"a": "b17e04cbb22afa8ffbff8796fc1894ed27badd9e"}, nil, nil, []byte(`{"a": "z"}`), true, false}, {"payload-hash-sha1", "", "secret", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "b17e04cbb22afa8ffbff8796fc1894ed27badd9e"}, nil, nil, []byte(`{"a": "z"}`), true, false},
// failures // failures
{"value", "", "", "X", Argument{"header", "a", ""}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false, false}, {"value", "", "", "X", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, false, false},
{"regex", "^X", "", "", Argument{"header", "a", ""}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false, false}, {"regex", "^X", "", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, false, false},
{"value", "", "2", "X", Argument{"header", "a", ""}, &map[string]interface{}{"y": "z"}, nil, nil, []byte{}, false, false}, // reference invalid header {"value", "", "2", "X", Argument{"header", "a", ""}, &map[string]interface{}{"Y": "z"}, nil, nil, []byte{}, false, false}, // reference invalid header
// errors // errors
{"regex", "*", "", "", Argument{"header", "a", ""}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false, true}, // invalid regex {"regex", "*", "", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, false, true}, // invalid regex
{"payload-hash-sha1", "", "secret", "", Argument{"header", "a", ""}, &map[string]interface{}{"a": ""}, nil, nil, []byte{}, false, true}, // invalid hmac {"payload-hash-sha1", "", "secret", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": ""}, nil, nil, []byte{}, false, true}, // invalid hmac
} }
func TestMatchRule(t *testing.T) { func TestMatchRule(t *testing.T) {
@@ -284,7 +284,7 @@ var andRuleTests = []struct {
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}}, {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}},
}, },
&map[string]interface{}{"a": "z", "b": "y"}, nil, nil, []byte{}, &map[string]interface{}{"A": "z", "B": "y"}, nil, nil, []byte{},
true, false, true, false,
}, },
{ {
@@ -293,7 +293,7 @@ var andRuleTests = []struct {
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}}, {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}},
}, },
&map[string]interface{}{"a": "z", "b": "Y"}, nil, nil, []byte{}, &map[string]interface{}{"A": "z", "B": "Y"}, nil, nil, []byte{},
false, false, false, false,
}, },
// Complex test to cover Rules.Evaluate // Complex test to cover Rules.Evaluate
@@ -319,7 +319,7 @@ var andRuleTests = []struct {
}, },
}, },
}, },
&map[string]interface{}{"a": "z", "b": "y", "c": "x", "d": "w", "e": "X", "f": "X"}, nil, nil, []byte{}, &map[string]interface{}{"A": "z", "B": "y", "C": "x", "D": "w", "E": "X", "F": "X"}, nil, nil, []byte{},
true, false, true, false,
}, },
{"empty rule", AndRule{{}}, nil, nil, nil, nil, false, false}, {"empty rule", AndRule{{}}, nil, nil, nil, nil, false, false},
@@ -327,7 +327,7 @@ var andRuleTests = []struct {
{ {
"invalid rule", "invalid rule",
AndRule{{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", ""}}}}, AndRule{{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", ""}}}},
&map[string]interface{}{"y": "z"}, nil, nil, nil, &map[string]interface{}{"Y": "z"}, nil, nil, nil,
false, false, false, false,
}, },
} }
@@ -355,7 +355,7 @@ var orRuleTests = []struct {
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}}, {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}},
}, },
&map[string]interface{}{"a": "z", "b": "X"}, nil, nil, []byte{}, &map[string]interface{}{"A": "z", "B": "X"}, nil, nil, []byte{},
true, false, true, false,
}, },
{ {
@@ -364,7 +364,7 @@ var orRuleTests = []struct {
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}}, {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}},
}, },
&map[string]interface{}{"a": "X", "b": "y"}, nil, nil, []byte{}, &map[string]interface{}{"A": "X", "B": "y"}, nil, nil, []byte{},
true, false, true, false,
}, },
{ {
@@ -373,7 +373,7 @@ var orRuleTests = []struct {
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}}, {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}},
}, },
&map[string]interface{}{"a": "Z", "b": "Y"}, nil, nil, []byte{}, &map[string]interface{}{"A": "Z", "B": "Y"}, nil, nil, []byte{},
false, false, false, false,
}, },
// failures // failures
@@ -382,7 +382,7 @@ var orRuleTests = []struct {
OrRule{ OrRule{
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
}, },
&map[string]interface{}{"y": "Z"}, nil, nil, []byte{}, &map[string]interface{}{"Y": "Z"}, nil, nil, []byte{},
false, false, false, false,
}, },
} }
@@ -404,8 +404,8 @@ var notRuleTests = []struct {
ok bool ok bool
err bool err bool
}{ }{
{"(a=z): !a=X", NotRule{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", ""}}}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, true, false}, {"(a=z): !a=X", NotRule{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", ""}}}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, true, false},
{"(a=z): !a=z", NotRule{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false, false}, {"(a=z): !a=z", NotRule{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, false, false},
} }
func TestNotRule(t *testing.T) { func TestNotRule(t *testing.T) {
+1 -1
View File
@@ -26,7 +26,7 @@ func watchForSignals() {
if sig == syscall.SIGUSR1 { if sig == syscall.SIGUSR1 {
log.Println("caught USR1 signal") log.Println("caught USR1 signal")
reloadHooks() reloadAllHooks()
} else { } else {
log.Printf("caught unhandled signal %+v\n", sig) log.Printf("caught unhandled signal %+v\n", sig)
} }
+2
View File
@@ -4,6 +4,7 @@
"execute-command": "{{ .Hookecho }}", "execute-command": "{{ .Hookecho }}",
"command-working-directory": "/", "command-working-directory": "/",
"include-command-output-in-response": true, "include-command-output-in-response": true,
"trigger-rule-mismatch-http-response-code": 400,
"pass-environment-to-command": "pass-environment-to-command":
[ [
{ {
@@ -59,6 +60,7 @@
"command-working-directory": "/", "command-working-directory": "/",
"include-command-output-in-response": false, "include-command-output-in-response": false,
"response-message": "success", "response-message": "success",
"trigger-rule-mismatch-http-response-code": 999,
"parse-parameters-as-json": [ "parse-parameters-as-json": [
{ {
"source": "payload", "source": "payload",
+164 -52
View File
@@ -21,7 +21,7 @@ import (
) )
const ( const (
version = "2.5.0" version = "2.6.2"
) )
var ( var (
@@ -30,27 +30,55 @@ var (
verbose = flag.Bool("verbose", false, "show verbose output") 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") 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") 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)") 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") secure = flag.Bool("secure", false, "use HTTPS instead of HTTP")
cert = flag.String("cert", "cert.pem", "path to the HTTPS certificate pem file") 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") key = flag.String("key", "key.pem", "path to the HTTPS certificate private key pem file")
justDisplayVersion = flag.Bool("version", false, "display webhook version and quit")
responseHeaders hook.ResponseHeaders responseHeaders hook.ResponseHeaders
hooksFiles hook.HooksFiles
loadedHooksFromFiles = make(map[string]hook.Hooks)
watcher *fsnotify.Watcher watcher *fsnotify.Watcher
signals chan os.Signal signals chan os.Signal
hooks hook.Hooks
) )
func main() { func matchLoadedHook(id string) *hook.Hook {
hooks = hook.Hooks{} for _, hooks := range loadedHooksFromFiles {
if hook := hooks.Match(id); hook != nil {
return hook
}
}
return nil
}
func lenLoadedHooks() int {
sum := 0
for _, hooks := range loadedHooksFromFiles {
sum += len(hooks)
}
return sum
}
func main() {
flag.Var(&hooksFiles, "hooks", "path to the json file containing defined hooks the webhook should serve, use multiple times to load from different files")
flag.Var(&responseHeaders, "header", "response header to return, specified in format name=value, use multiple times to set multiple headers") flag.Var(&responseHeaders, "header", "response header to return, specified in format name=value, use multiple times to set multiple headers")
flag.Parse() flag.Parse()
if *justDisplayVersion {
fmt.Println("webhook version " + version)
os.Exit(0)
}
if len(hooksFiles) == 0 {
hooksFiles = append(hooksFiles, "hooks.json")
}
log.SetPrefix("[webhook] ") log.SetPrefix("[webhook] ")
log.SetFlags(log.Ldate | log.Ltime) log.SetFlags(log.Ldate | log.Ltime)
@@ -64,52 +92,65 @@ func main() {
setupSignals() setupSignals()
// load and parse hooks // load and parse hooks
log.Printf("attempting to load hooks from %s\n", *hooksFilePath) for _, hooksFilePath := range hooksFiles {
log.Printf("attempting to load hooks from %s\n", hooksFilePath)
err := hooks.LoadFromFile(*hooksFilePath) newHooks := hook.Hooks{}
err := newHooks.LoadFromFile(hooksFilePath)
if err != nil { 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) log.Printf("couldn't load hooks from file! %+v\n", err)
} else { } else {
seenHooksIds := make(map[string]bool) log.Printf("found %d hook(s) in file\n", len(newHooks))
log.Printf("found %d hook(s) in file\n", len(hooks)) for _, hook := range newHooks {
if matchLoadedHook(hook.ID) != nil {
for _, hook := range hooks {
if seenHooksIds[hook.ID] == true {
log.Fatalf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!\n", hook.ID) log.Fatalf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!\n", hook.ID)
} }
seenHooksIds[hook.ID] = true
log.Printf("\tloaded: %s\n", hook.ID) log.Printf("\tloaded: %s\n", hook.ID)
} }
loadedHooksFromFiles[hooksFilePath] = newHooks
}
}
newHooksFiles := hooksFiles[:0]
for _, filePath := range hooksFiles {
if _, ok := loadedHooksFromFiles[filePath]; ok == true {
newHooksFiles = append(newHooksFiles, filePath)
}
}
hooksFiles = newHooksFiles
if !*verbose && !*noPanic && lenLoadedHooks() == 0 {
log.SetOutput(os.Stdout)
log.Fatalln("couldn't load any hooks from file!\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to start without the hooks, either use -verbose flag, or -nopanic")
} }
if *hotReload { if *hotReload {
// set up file watcher
log.Printf("setting up file watcher for %s\n", *hooksFilePath)
var err error var err error
watcher, err = fsnotify.NewWatcher() watcher, err = fsnotify.NewWatcher()
if err != nil { if err != nil {
log.Fatal("error creating file watcher instance", err) log.Fatal("error creating file watcher instance\n", err)
} }
defer watcher.Close() defer watcher.Close()
go watchForFileChange() for _, hooksFilePath := range hooksFiles {
// set up file watcher
log.Printf("setting up file watcher for %s\n", hooksFilePath)
err = watcher.Add(*hooksFilePath) err = watcher.Add(hooksFilePath)
if err != nil { if err != nil {
log.Fatal("error adding hooks file to the watcher", err) log.Fatal("error adding hooks file to the watcher\n", err)
} }
} }
go watchForFileChange()
}
l := negroni.NewLogger() l := negroni.NewLogger()
l.ALogger = log.New(os.Stderr, "[webhook] ", log.Ldate|log.Ltime) l.ALogger = log.New(os.Stderr, "[webhook] ", log.Ldate|log.Ltime)
@@ -153,7 +194,7 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"] id := mux.Vars(r)["id"]
if matchedHook := hooks.Match(id); matchedHook != nil { if matchedHook := matchLoadedHook(id); matchedHook != nil {
log.Printf("%s got matched\n", id) log.Printf("%s got matched\n", id)
body, err := ioutil.ReadAll(r.Body) body, err := ioutil.ReadAll(r.Body)
@@ -191,12 +232,10 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
} }
// handle hook // handle hook
if err = matchedHook.ParseJSONParameters(&headers, &query, &payload); err != nil { if errors := matchedHook.ParseJSONParameters(&headers, &query, &payload); errors != nil {
msg := fmt.Sprintf("error parsing JSON parameters: %s", err) for _, err := range errors {
log.Printf(msg) log.Printf("error parsing JSON parameters: %s\n", err)
w.WriteHeader(http.StatusBadRequest) }
fmt.Fprintf(w, "Unable to parse JSON parameters.")
return
} }
var ok bool var ok bool
@@ -238,6 +277,17 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Check if a return code is configured for the hook
if matchedHook.TriggerRuleMismatchHttpResponseCode != 0 {
// Check if the configured return code is supported by the http package
// by testing if there is a StatusText for this code.
if len(http.StatusText(matchedHook.TriggerRuleMismatchHttpResponseCode)) > 0 {
w.WriteHeader(matchedHook.TriggerRuleMismatchHttpResponseCode)
} else {
log.Printf("%s got matched, but the configured return code %d is unknown - defaulting to 200\n", matchedHook.ID, matchedHook.TriggerRuleMismatchHttpResponseCode)
}
}
// if none of the hooks got triggered // if none of the hooks got triggered
log.Printf("%s got matched, but didn't get triggered because the trigger rules were not satisfied\n", matchedHook.ID) log.Printf("%s got matched, but didn't get triggered because the trigger rules were not satisfied\n", matchedHook.ID)
@@ -249,21 +299,27 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
} }
func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, body *[]byte) (string, error) { func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, body *[]byte) (string, error) {
var err error var errors []error
cmd := exec.Command(h.ExecuteCommand) cmd := exec.Command(h.ExecuteCommand)
cmd.Dir = h.CommandWorkingDirectory cmd.Dir = h.CommandWorkingDirectory
cmd.Args, err = h.ExtractCommandArguments(headers, query, payload) cmd.Args, errors = h.ExtractCommandArguments(headers, query, payload)
if err != nil { if errors != nil {
log.Printf("error extracting command arguments: %s", err) for _, err := range errors {
log.Printf("error extracting command arguments: %s\n", err)
}
} }
var envs []string var envs []string
envs, err = h.ExtractCommandArgumentsForEnv(headers, query, payload) envs, errors = h.ExtractCommandArgumentsForEnv(headers, query, payload)
if err != nil {
log.Printf("error extracting command arguments for environment: %s", err) if errors != nil {
for _, err := range errors {
log.Printf("error extracting command arguments for environment: %s\n", err)
} }
}
cmd.Env = append(os.Environ(), envs...) cmd.Env = append(os.Environ(), envs...)
log.Printf("executing %s (%s) with arguments %q and environment %s using %s as cwd\n", h.ExecuteCommand, cmd.Path, cmd.Args, envs, cmd.Dir) log.Printf("executing %s (%s) with arguments %q and environment %s using %s as cwd\n", h.ExecuteCommand, cmd.Path, cmd.Args, envs, cmd.Dir)
@@ -281,32 +337,74 @@ func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, b
return string(out), err return string(out), err
} }
func reloadHooks() { func reloadHooks(hooksFilePath string) {
newHooks := hook.Hooks{} hooksInFile := hook.Hooks{}
// parse and swap // parse and swap
log.Printf("attempting to reload hooks from %s\n", *hooksFilePath) log.Printf("attempting to reload hooks from %s\n", hooksFilePath)
err := newHooks.LoadFromFile(*hooksFilePath) err := hooksInFile.LoadFromFile(hooksFilePath)
if err != nil { if err != nil {
log.Printf("couldn't load hooks from file! %+v\n", err) log.Printf("couldn't load hooks from file! %+v\n", err)
} else { } else {
seenHooksIds := make(map[string]bool) seenHooksIds := make(map[string]bool)
log.Printf("found %d hook(s) in file\n", len(newHooks)) log.Printf("found %d hook(s) in file\n", len(hooksInFile))
for _, hook := range newHooks { for _, hook := range hooksInFile {
if seenHooksIds[hook.ID] == true { wasHookIDAlreadyLoaded := false
for _, loadedHook := range loadedHooksFromFiles[hooksFilePath] {
if loadedHook.ID == hook.ID {
wasHookIDAlreadyLoaded = true
break
}
}
if (matchLoadedHook(hook.ID) != nil && !wasHookIDAlreadyLoaded) || seenHooksIds[hook.ID] == true {
log.Printf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!", hook.ID) log.Printf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!", hook.ID)
log.Println("reverting hooks back to the previous configuration") log.Println("reverting hooks back to the previous configuration")
return return
} }
seenHooksIds[hook.ID] = true seenHooksIds[hook.ID] = true
log.Printf("\tloaded: %s\n", hook.ID) log.Printf("\tloaded: %s\n", hook.ID)
} }
hooks = newHooks loadedHooksFromFiles[hooksFilePath] = hooksInFile
}
}
func reloadAllHooks() {
for _, hooksFilePath := range hooksFiles {
reloadHooks(hooksFilePath)
}
}
func removeHooks(hooksFilePath string) {
for _, hook := range loadedHooksFromFiles[hooksFilePath] {
log.Printf("\tremoving: %s\n", hook.ID)
}
newHooksFiles := hooksFiles[:0]
for _, filePath := range hooksFiles {
if filePath != hooksFilePath {
newHooksFiles = append(newHooksFiles, filePath)
}
}
hooksFiles = newHooksFiles
removedHooksCount := len(loadedHooksFromFiles[hooksFilePath])
delete(loadedHooksFromFiles, hooksFilePath)
log.Printf("removed %d hook(s) that were loaded from file %s\n", removedHooksCount, hooksFilePath)
if !*verbose && !*noPanic && lenLoadedHooks() == 0 {
log.SetOutput(os.Stdout)
log.Fatalln("couldn't load any hooks from file!\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to run without the hooks, either use -verbose flag, or -nopanic")
} }
} }
@@ -315,9 +413,23 @@ func watchForFileChange() {
select { select {
case event := <-(*watcher).Events: case event := <-(*watcher).Events:
if event.Op&fsnotify.Write == fsnotify.Write { if event.Op&fsnotify.Write == fsnotify.Write {
log.Println("hooks file modified") log.Printf("hooks file %s modified\n", event.Name)
reloadHooks(event.Name)
reloadHooks() } else if event.Op&fsnotify.Remove == fsnotify.Remove {
log.Printf("hooks file %s removed, no longer watching this file for changes, removing hooks that were loaded from it\n", event.Name)
(*watcher).Remove(event.Name)
removeHooks(event.Name)
} else if event.Op&fsnotify.Rename == fsnotify.Rename {
if _, err := os.Stat(event.Name); os.IsNotExist(err) {
// file was removed
log.Printf("hooks file %s removed, no longer watching this file for changes, and removing hooks that were loaded from it\n", event.Name)
(*watcher).Remove(event.Name)
removeHooks(event.Name)
} else {
// file was overwritten
log.Printf("hooks file %s overwritten\n", event.Name)
reloadHooks(event.Name)
}
} }
case err := <-(*watcher).Errors: case err := <-(*watcher).Errors:
log.Println("watcher error:", err) log.Println("watcher error:", err)
+6 -1
View File
@@ -515,5 +515,10 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
`, `,
}, },
{"empty payload", "github", nil, `{}`, false, http.StatusOK, `Hook rules were not satisfied.`}, // test with custom return code
{"empty payload", "github", nil, `{}`, false, http.StatusBadRequest, `Hook rules were not satisfied.`},
// test with custom invalid http code, should default to 200 OK
{"empty payload", "bitbucket", nil, `{}`, false, http.StatusOK, `Hook rules were not satisfied.`},
// test with no configured http return code, should default to 200 OK
{"empty payload", "gitlab", nil, `{}`, false, http.StatusOK, `Hook rules were not satisfied.`},
} }