mirror of
https://github.com/adnanh/webhook.git
synced 2026-07-26 17:19:17 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8803239e6d | |||
| c8a83349d2 | |||
| 1da40d4634 | |||
| c51971fd37 | |||
| 93e5fe7712 | |||
| 058f820cbd | |||
| ddb1f2441a | |||
| 8226d5e50f | |||
| e149c99724 | |||
| 6ea2d68b44 | |||
| 79453b7a22 | |||
| 8207c6cf12 | |||
| 5789362186 | |||
| 07f166616c | |||
| b66216675a | |||
| ecbcf11153 | |||
| 7d525cf317 | |||
| e83d7029ff | |||
| 36c2c692d6 |
@@ -1,5 +1,4 @@
|
||||
[](https://ghit.me/repo/adnanh/webhook)[](https://gitter.im/adnanh/webhook?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [](https://flattr.com/submit/auto?user_id=adnanh&url=https%3A%2F%2Fwww.github.com%2Fadnanh%2Fwebhook)
|
||||
|
||||
[](https://ghit.me/repo/adnanh/webhook) [](https://gitter.im/adnanh/webhook?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [](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?
|
||||
[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
|
||||
## 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
|
||||
```bash
|
||||
$ go get 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.
|
||||
|
||||
Let's define a simple hook named `redeploy-webhook` that will run a redeploy script located in `/var/scripts/redeploy.sh`.
|
||||
|
||||
+81
-24
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/textproto"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
const (
|
||||
SourceHeader string = "header"
|
||||
SourceQuery string = "url"
|
||||
SourceQueryAlias string = "query"
|
||||
SourcePayload string = "payload"
|
||||
SourceString string = "string"
|
||||
SourceEntirePayload string = "entire-payload"
|
||||
@@ -205,11 +207,13 @@ type Argument struct {
|
||||
// based on the Argument's source
|
||||
func (ha *Argument) Get(headers, query, payload *map[string]interface{}) (string, bool) {
|
||||
var source *map[string]interface{}
|
||||
key := ha.Name
|
||||
|
||||
switch ha.Source {
|
||||
case SourceHeader:
|
||||
source = headers
|
||||
case SourceQuery:
|
||||
key = textproto.CanonicalMIMEHeaderKey(ha.Name)
|
||||
case SourceQuery, SourceQueryAlias:
|
||||
source = query
|
||||
case SourcePayload:
|
||||
source = payload
|
||||
@@ -242,7 +246,7 @@ func (ha *Argument) Get(headers, query, payload *map[string]interface{}) (string
|
||||
}
|
||||
|
||||
if source != nil {
|
||||
return ExtractParameterAsString(ha.Name, *source)
|
||||
return ExtractParameterAsString(key, *source)
|
||||
}
|
||||
|
||||
return "", false
|
||||
@@ -269,7 +273,7 @@ func (h *ResponseHeaders) String() string {
|
||||
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
|
||||
@@ -284,23 +288,43 @@ func (h *ResponseHeaders) Set(value string) error {
|
||||
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
|
||||
type Hook struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
ExecuteCommand string `json:"execute-command,omitempty"`
|
||||
CommandWorkingDirectory string `json:"command-working-directory,omitempty"`
|
||||
ResponseMessage string `json:"response-message,omitempty"`
|
||||
ResponseHeaders ResponseHeaders `json:"response-headers,omitempty"`
|
||||
CaptureCommandOutput bool `json:"include-command-output-in-response,omitempty"`
|
||||
PassEnvironmentToCommand []Argument `json:"pass-environment-to-command,omitempty"`
|
||||
PassArgumentsToCommand []Argument `json:"pass-arguments-to-command,omitempty"`
|
||||
JSONStringParameters []Argument `json:"parse-parameters-as-json,omitempty"`
|
||||
TriggerRule *Rules `json:"trigger-rule,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
ExecuteCommand string `json:"execute-command,omitempty"`
|
||||
CommandWorkingDirectory string `json:"command-working-directory,omitempty"`
|
||||
ResponseMessage string `json:"response-message,omitempty"`
|
||||
ResponseHeaders ResponseHeaders `json:"response-headers,omitempty"`
|
||||
CaptureCommandOutput bool `json:"include-command-output-in-response,omitempty"`
|
||||
PassEnvironmentToCommand []Argument `json:"pass-environment-to-command,omitempty"`
|
||||
PassArgumentsToCommand []Argument `json:"pass-arguments-to-command,omitempty"`
|
||||
JSONStringParameters []Argument `json:"parse-parameters-as-json,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
|
||||
// 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 {
|
||||
if arg, ok := h.JSONStringParameters[i].Get(headers, query, payload); ok {
|
||||
var newArg map[string]interface{}
|
||||
@@ -311,7 +335,8 @@ func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface
|
||||
err := decoder.Decode(&newArg)
|
||||
|
||||
if err != nil {
|
||||
return &ParseError{err}
|
||||
errors = append(errors, &ParseError{err})
|
||||
continue
|
||||
}
|
||||
|
||||
var source *map[string]interface{}
|
||||
@@ -321,27 +346,38 @@ func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface
|
||||
source = headers
|
||||
case SourcePayload:
|
||||
source = payload
|
||||
case SourceQuery:
|
||||
case SourceQuery, SourceQueryAlias:
|
||||
source = query
|
||||
}
|
||||
|
||||
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 {
|
||||
return &SourceError{h.JSONStringParameters[i]}
|
||||
errors = append(errors, &SourceError{h.JSONStringParameters[i]})
|
||||
}
|
||||
} else {
|
||||
return &ArgumentError{h.JSONStringParameters[i]}
|
||||
errors = append(errors, &ArgumentError{h.JSONStringParameters[i]})
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return errors
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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, error) {
|
||||
func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]interface{}) ([]string, []error) {
|
||||
var args = make([]string, 0)
|
||||
var errors = make([]error, 0)
|
||||
|
||||
args = append(args, h.ExecuteCommand)
|
||||
|
||||
@@ -350,19 +386,23 @@ func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]inter
|
||||
args = append(args, arg)
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
// ExtractCommandArgumentsForEnv creates a list of arguments in key=value
|
||||
// format, based on the PassEnvironmentToCommand property that is ready to be used
|
||||
// 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 errors = make([]error, 0)
|
||||
for i := range h.PassEnvironmentToCommand {
|
||||
if arg, ok := h.PassEnvironmentToCommand[i].Get(headers, query, payload); ok {
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
return args, &ArgumentError{h.PassEnvironmentToCommand[i]}
|
||||
errors = append(errors, &ArgumentError{h.PassEnvironmentToCommand[i]})
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return args, errors
|
||||
}
|
||||
|
||||
return args, nil
|
||||
}
|
||||
|
||||
@@ -400,6 +444,19 @@ func (h *Hooks) LoadFromFile(path string) error {
|
||||
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,
|
||||
// if no hook matches the given ID, nil is returned
|
||||
func (h *Hooks) Match(id string) *Hook {
|
||||
|
||||
+31
-31
@@ -72,15 +72,15 @@ var argumentGetTests = []struct {
|
||||
value string
|
||||
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},
|
||||
{"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
|
||||
{"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) {
|
||||
@@ -99,14 +99,14 @@ var hookParseJSONParametersTests = []struct {
|
||||
rheaders, rquery, rpayload *map[string]interface{}
|
||||
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{"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
|
||||
{[]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{"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) {
|
||||
@@ -126,9 +126,9 @@ var hookExtractCommandArgumentsTests = []struct {
|
||||
value []string
|
||||
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
|
||||
{"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) {
|
||||
@@ -171,14 +171,14 @@ var hookExtractCommandArgumentsForEnvTests = []struct {
|
||||
{
|
||||
"test",
|
||||
[]Argument{Argument{"header", "a", ""}},
|
||||
&map[string]interface{}{"a": "z"}, nil, nil,
|
||||
&map[string]interface{}{"A": "z"}, nil, nil,
|
||||
[]string{"HOOK_a=z"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"test",
|
||||
[]Argument{Argument{"header", "a", "MYKEY"}},
|
||||
&map[string]interface{}{"a": "z"}, nil, nil,
|
||||
&map[string]interface{}{"A": "z"}, nil, nil,
|
||||
[]string{"MYKEY=z"},
|
||||
true,
|
||||
},
|
||||
@@ -186,7 +186,7 @@ var hookExtractCommandArgumentsForEnvTests = []struct {
|
||||
{
|
||||
"fail",
|
||||
[]Argument{Argument{"payload", "a", ""}},
|
||||
&map[string]interface{}{"a": "z"}, nil, nil,
|
||||
&map[string]interface{}{"A": "z"}, nil, nil,
|
||||
[]string{},
|
||||
false,
|
||||
},
|
||||
@@ -248,16 +248,16 @@ var matchRuleTests = []struct {
|
||||
ok bool
|
||||
err bool
|
||||
}{
|
||||
{"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},
|
||||
{"payload-hash-sha1", "", "secret", "", Argument{"header", "a", ""}, &map[string]interface{}{"a": "b17e04cbb22afa8ffbff8796fc1894ed27badd9e"}, nil, nil, []byte(`{"a": "z"}`), 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},
|
||||
{"payload-hash-sha1", "", "secret", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "b17e04cbb22afa8ffbff8796fc1894ed27badd9e"}, nil, nil, []byte(`{"a": "z"}`), true, false},
|
||||
// failures
|
||||
{"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},
|
||||
{"value", "", "2", "X", Argument{"header", "a", ""}, &map[string]interface{}{"y": "z"}, nil, nil, []byte{}, false, false}, // reference invalid header
|
||||
{"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},
|
||||
{"value", "", "2", "X", Argument{"header", "a", ""}, &map[string]interface{}{"Y": "z"}, nil, nil, []byte{}, false, false}, // reference invalid header
|
||||
// errors
|
||||
{"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
|
||||
{"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
|
||||
}
|
||||
|
||||
func TestMatchRule(t *testing.T) {
|
||||
@@ -284,7 +284,7 @@ var andRuleTests = []struct {
|
||||
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
|
||||
{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,
|
||||
},
|
||||
{
|
||||
@@ -293,7 +293,7 @@ var andRuleTests = []struct {
|
||||
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
|
||||
{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,
|
||||
},
|
||||
// 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,
|
||||
},
|
||||
{"empty rule", AndRule{{}}, nil, nil, nil, nil, false, false},
|
||||
@@ -327,7 +327,7 @@ var andRuleTests = []struct {
|
||||
{
|
||||
"invalid rule",
|
||||
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,
|
||||
},
|
||||
}
|
||||
@@ -355,7 +355,7 @@ var orRuleTests = []struct {
|
||||
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
|
||||
{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,
|
||||
},
|
||||
{
|
||||
@@ -364,7 +364,7 @@ var orRuleTests = []struct {
|
||||
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
|
||||
{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,
|
||||
},
|
||||
{
|
||||
@@ -373,7 +373,7 @@ var orRuleTests = []struct {
|
||||
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
|
||||
{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,
|
||||
},
|
||||
// failures
|
||||
@@ -382,7 +382,7 @@ var orRuleTests = []struct {
|
||||
OrRule{
|
||||
{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,
|
||||
},
|
||||
}
|
||||
@@ -404,8 +404,8 @@ var notRuleTests = []struct {
|
||||
ok 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=z", NotRule{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false, 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},
|
||||
}
|
||||
|
||||
func TestNotRule(t *testing.T) {
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ func watchForSignals() {
|
||||
if sig == syscall.SIGUSR1 {
|
||||
log.Println("caught USR1 signal")
|
||||
|
||||
reloadHooks()
|
||||
reloadAllHooks()
|
||||
} else {
|
||||
log.Printf("caught unhandled signal %+v\n", sig)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"execute-command": "{{ .Hookecho }}",
|
||||
"command-working-directory": "/",
|
||||
"include-command-output-in-response": true,
|
||||
"trigger-rule-mismatch-http-response-code": 400,
|
||||
"pass-environment-to-command":
|
||||
[
|
||||
{
|
||||
@@ -59,6 +60,7 @@
|
||||
"command-working-directory": "/",
|
||||
"include-command-output-in-response": false,
|
||||
"response-message": "success",
|
||||
"trigger-rule-mismatch-http-response-code": 999,
|
||||
"parse-parameters-as-json": [
|
||||
{
|
||||
"source": "payload",
|
||||
|
||||
+178
-66
@@ -21,36 +21,64 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
version = "2.5.0"
|
||||
version = "2.6.2"
|
||||
)
|
||||
|
||||
var (
|
||||
ip = flag.String("ip", "0.0.0.0", "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")
|
||||
ip = flag.String("ip", "0.0.0.0", "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")
|
||||
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")
|
||||
justDisplayVersion = flag.Bool("version", false, "display webhook version and quit")
|
||||
|
||||
responseHeaders hook.ResponseHeaders
|
||||
hooksFiles hook.HooksFiles
|
||||
|
||||
loadedHooksFromFiles = make(map[string]hook.Hooks)
|
||||
|
||||
watcher *fsnotify.Watcher
|
||||
signals chan os.Signal
|
||||
|
||||
hooks hook.Hooks
|
||||
)
|
||||
|
||||
func main() {
|
||||
hooks = hook.Hooks{}
|
||||
func matchLoadedHook(id string) *hook.Hook {
|
||||
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.Parse()
|
||||
|
||||
if *justDisplayVersion {
|
||||
fmt.Println("webhook version " + version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if len(hooksFiles) == 0 {
|
||||
hooksFiles = append(hooksFiles, "hooks.json")
|
||||
}
|
||||
|
||||
log.SetPrefix("[webhook] ")
|
||||
log.SetFlags(log.Ldate | log.Ltime)
|
||||
|
||||
@@ -64,50 +92,63 @@ func main() {
|
||||
setupSignals()
|
||||
|
||||
// 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{}
|
||||
|
||||
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)
|
||||
}
|
||||
err := newHooks.LoadFromFile(hooksFilePath)
|
||||
|
||||
log.Printf("couldn't load hooks from file! %+v\n", err)
|
||||
} else {
|
||||
seenHooksIds := make(map[string]bool)
|
||||
if err != nil {
|
||||
log.Printf("couldn't load hooks from file! %+v\n", err)
|
||||
} else {
|
||||
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 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)
|
||||
for _, hook := range newHooks {
|
||||
if matchLoadedHook(hook.ID) != nil {
|
||||
log.Fatalf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!\n", hook.ID)
|
||||
}
|
||||
log.Printf("\tloaded: %s\n", hook.ID)
|
||||
}
|
||||
seenHooksIds[hook.ID] = true
|
||||
log.Printf("\tloaded: %s\n", hook.ID)
|
||||
|
||||
loadedHooksFromFiles[hooksFilePath] = newHooks
|
||||
}
|
||||
}
|
||||
|
||||
if *hotReload {
|
||||
// set up file watcher
|
||||
log.Printf("setting up file watcher for %s\n", *hooksFilePath)
|
||||
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 {
|
||||
var err error
|
||||
|
||||
watcher, err = fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log.Fatal("error creating file watcher instance", err)
|
||||
log.Fatal("error creating file watcher instance\n", err)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
log.Fatal("error adding hooks file to the watcher", err)
|
||||
err = watcher.Add(hooksFilePath)
|
||||
if err != nil {
|
||||
log.Fatal("error adding hooks file to the watcher\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
go watchForFileChange()
|
||||
}
|
||||
|
||||
l := negroni.NewLogger()
|
||||
@@ -153,7 +194,7 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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)
|
||||
|
||||
body, err := ioutil.ReadAll(r.Body)
|
||||
@@ -191,12 +232,10 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// handle hook
|
||||
if err = matchedHook.ParseJSONParameters(&headers, &query, &payload); err != nil {
|
||||
msg := fmt.Sprintf("error parsing JSON parameters: %s", err)
|
||||
log.Printf(msg)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "Unable to parse JSON parameters.")
|
||||
return
|
||||
if errors := matchedHook.ParseJSONParameters(&headers, &query, &payload); errors != nil {
|
||||
for _, err := range errors {
|
||||
log.Printf("error parsing JSON parameters: %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
var ok bool
|
||||
@@ -238,6 +277,17 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
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) {
|
||||
var err error
|
||||
var errors []error
|
||||
|
||||
cmd := exec.Command(h.ExecuteCommand)
|
||||
cmd.Dir = h.CommandWorkingDirectory
|
||||
|
||||
cmd.Args, err = h.ExtractCommandArguments(headers, query, payload)
|
||||
if err != nil {
|
||||
log.Printf("error extracting command arguments: %s", err)
|
||||
cmd.Args, errors = h.ExtractCommandArguments(headers, query, payload)
|
||||
if errors != nil {
|
||||
for _, err := range errors {
|
||||
log.Printf("error extracting command arguments: %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
var envs []string
|
||||
envs, err = h.ExtractCommandArgumentsForEnv(headers, query, payload)
|
||||
if err != nil {
|
||||
log.Printf("error extracting command arguments for environment: %s", err)
|
||||
envs, errors = h.ExtractCommandArgumentsForEnv(headers, query, payload)
|
||||
|
||||
if errors != nil {
|
||||
for _, err := range errors {
|
||||
log.Printf("error extracting command arguments for environment: %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -281,32 +337,74 @@ func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, b
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func reloadHooks() {
|
||||
newHooks := hook.Hooks{}
|
||||
func reloadHooks(hooksFilePath string) {
|
||||
hooksInFile := hook.Hooks{}
|
||||
|
||||
// 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 {
|
||||
log.Printf("couldn't load hooks from file! %+v\n", err)
|
||||
} 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(hooksInFile))
|
||||
|
||||
for _, hook := range newHooks {
|
||||
if seenHooksIds[hook.ID] == true {
|
||||
for _, hook := range hooksInFile {
|
||||
wasHookIDAlreadyLoaded := false
|
||||
|
||||
for _, loadedHook := range loadedHooksFromFiles[hooksFilePath] {
|
||||
if loadedHook.ID == hook.ID {
|
||||
wasHookIDAlreadyLoaded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matchLoadedHook(hook.ID) != nil && !wasHookIDAlreadyLoaded) || seenHooksIds[hook.ID] == 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.Println("reverting hooks back to the previous configuration")
|
||||
return
|
||||
}
|
||||
|
||||
seenHooksIds[hook.ID] = true
|
||||
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 {
|
||||
case event := <-(*watcher).Events:
|
||||
if event.Op&fsnotify.Write == fsnotify.Write {
|
||||
log.Println("hooks file modified")
|
||||
|
||||
reloadHooks()
|
||||
log.Printf("hooks file %s modified\n", event.Name)
|
||||
reloadHooks(event.Name)
|
||||
} 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:
|
||||
log.Println("watcher error:", err)
|
||||
|
||||
+6
-1
@@ -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.`},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user