Compare commits

...

46 Commits

Author SHA1 Message Date
Adnan Hajdarević 5594a62f8f Merge pull request #61 from adnanh/development
- added omitempty to json fields
2015-12-27 20:04:03 +01:00
Adnan Hajdarevic f59f0a5c84 - added omitempty to json fields 2015-12-27 20:05:52 +01:00
Adnan Hajdarevic 642516d46e Merge branch 'master' into development 2015-11-27 09:57:44 +01:00
Adnan Hajdarevic 9cef8ed882 add omitempty to json fields 2015-11-21 17:06:02 +01:00
Adnan Hajdarević 8de1a51926 Merge pull request #56 from adnanh/development
Bugfixes
2015-11-19 12:35:16 +01:00
Adnan Hajdarević ed9d557a13 Merge pull request #55 from moorereason/fixenv
Fix environment and argument passing
2015-11-19 09:53:31 +01:00
Cameron Moore d2e315d9c6 Fix environment and argument passing
Two issues are addressed in this commit:

1. Instead of only sending the predefined environment arguments, this
commit appends the arguments to the existing OS environment.  Fixes #53.

2. If an argument is not found in the payload, allow the command to run
and pass in an empty string as a placeholder.  Fixes #54.

Additionally, I replaced `hook.ErrInvalidPayloadSignature` with a new
`SignatureError` type so that we can embed the signature in the error.
2015-11-18 12:00:47 -06:00
Adnan Hajdarević a7aa7f2327 Merge pull request #52 from adnanh/development
2.3.6
2015-11-13 09:26:08 +01:00
Adnan Hajdarevic 82bba7ba07 bump up the version 2015-11-13 09:24:17 +01:00
Adnan Hajdarevic 8a9ff6d06c removed makefile conflict 2015-11-11 09:50:36 +01:00
Adnan Hajdarević 3080fd115b Merge pull request #51 from moorereason/focus
Remove webhook-contrib content and update README
2015-11-11 09:44:05 +01:00
Cameron Moore b314eda1f9 Remove webhook-contrib content and update README
The Docker, Travis-CI, RPM, and init files are being moved to the
separate `webhook-contrib` repository.
2015-11-06 15:09:29 -06:00
Adnan Hajdarević 8544653787 Merge pull request #49 from moorereason/earlyreturn
Remove unnecessary else clause
2015-11-03 21:14:32 +01:00
Cameron Moore 8d1498e285 Remove unnecessary else clause 2015-11-03 10:48:16 -06:00
Adnan Hajdarević 2ed15e35bc Merge pull request #48 from almir/development
Adjust Makefile syntax and add docker section in README.md
2015-11-03 10:13:46 +01:00
almir aff3a155d7 - fix hooks.json file name in readme 2015-11-03 09:28:42 +01:00
almir 5f041cb9ae - add docker section to readme 2015-11-03 09:25:53 +01:00
almir b7081f3934 - adjust Makefile syntax 2015-11-03 09:07:38 +01:00
Adnan Hajdarević 2026328c56 Merge pull request #47 from moorereason/hookecho
Add environment arguments and improve testing
2015-11-02 22:32:27 +01:00
Cameron Moore ea3dbf3438 Add environment arguments and improve testing
There's a lot in this commit.

 1. Add `pass-environment-to-command` option that works much like
 `pass-arguments-to-command`.  You can see an example usage in the
 "github" test case.

 2. Add a test program called "hookecho" that is used to test the
 webhook package instead of relying upon a system `echo` command.

 3. Move hooks_test.json to a template so that we can update the path to
 hookecho on the fly.

 4. Don't return an error at the end of hook.MatchRule.Evaluate().  All
 tests succeed for me now.
2015-11-02 15:11:23 -06:00
Adnan Hajdarević 6774079a57 Merge pull request #46 from moorereason/testing
Add testing framework for main webhook app
2015-10-31 09:29:34 +01:00
Adnan Hajdarević 334fbc814c Merge pull request #45 from moorereason/signals
Refactor signal handling and Windows support
2015-10-31 09:29:22 +01:00
Cameron Moore 802f3f572c Add testing framework for main webhook app
This commit adds a testing framework modeled after the godoc tests. It builds
webhook in a temporary directory, runs it with the supplied `hooks_test.json`
configuration, and then tests different payloads.  I use `/bin/echo` for the
test executable, so I've added build tags to exclude Windows.

Three minor (I hope) changes in functionality:

- I ended up moving everything from `init()` to `main()` because `init()` was
  firing while trying to build the tests, and it was dying since `hooks.json`
  didn't exist. I'm still not 100% sure `init()` was firing, but I didn't see
  any real need for anything to be in `init()` in the first place.

- make sure logger is using `os.Stderr`

- don't send `http.StatusBadRequest` when the Hook rules don't match.  "Bad
  Request" is used to identify malformed requests.  The request was properly
  formed and processed, so I think we should send back `http.StatusOK`.  For
  example, if I setup a webhook rule to only execute when commits are made to
  the `master` branch, we shouldn't send back `http.StatusBadRequest` when we
  ingest a payload for the `development` branch.

The test payloads are pretty verbose and could probably be shortened, but I kind
of like having an example payload for each service.  We can pare them down if we
want to do more focused, minimalist testing.
2015-10-30 21:32:48 -05:00
Adnan Hajdarević 6da00561bf Merge pull request #44 from timhughes/master
Pull request into development instead of master
2015-10-30 17:25:58 +01:00
Adnan Hajdarević 2e40847eb9 Merge pull request #43 from adnanh/revert-42-master
Revert "adding rpm spec file and sysv init script"
2015-10-30 17:22:48 +01:00
Adnan Hajdarević 5f7dd7211b Revert "adding rpm spec file and sysv init script" 2015-10-30 17:22:38 +01:00
Adnan Hajdarević db25fcd06d Merge pull request #42 from timhughes/master
adding rpm spec file and sysv init script
2015-10-30 17:22:03 +01:00
Tim Hughes d1b4ef767a Add travis test status badge to README.md 2015-10-30 01:17:06 +00:00
Tim Hughes 64b3256408 enable unit tests and add in init script and example hooks.json file to rpmspec 2015-10-30 00:49:38 +00:00
Tim Hughes bfe4c148a5 add in make target 2015-10-29 21:58:29 +00:00
Tim Hughes 033764601d typo in travis.yml 2015-10-29 21:51:43 +00:00
Tim Hughes 8173f4a346 add travis.yml 2015-10-29 21:50:46 +00:00
Tim Hughes ffd05a2101 adding a rpm spec file and init script 2015-10-29 21:43:13 +00:00
Adnan Hajdarević fea31474bd Merge pull request #41 from moorereason/devsidefx
Remove logging side-effects from hook package
2015-10-29 19:05:15 +01:00
Cameron Moore 1c319a7a08 Refactor signal handling and Windows support
This commit drops webhook_windows.go in favor of simply pulling out the
signal handling code to separate files.
2015-10-29 11:17:15 -05:00
Cameron Moore 2947e5e0e8 Remove logging side-effects from hook package
The hook package should be self-contained and return errors instead of
relying on the log subsystem.  This commit removes the logging
side-effects from the hook package.  Custom errors are returned that
should be transparent to the caller -- they can just treat them as
simple errors if they don't care to check the type.
2015-10-29 11:00:30 -05:00
Adnan Hajdarević 409b441c31 Merge pull request #40 from adnanh/development
Add Dockerfile & Makefile
2015-10-28 11:52:31 +01:00
Adnan Hajdarević 93505b4132 Merge pull request #39 from almir/development
- create dockerfile and makefile for building docker image
2015-10-28 11:34:11 +01:00
almir c350784507 - create dockerfile and makefile for building docker image 2015-10-28 10:54:36 +01:00
Adnan Hajdarević 7b8cc04992 Merge pull request #38 from adnanh/development
Fail if webhook cannot load hooks when not running in verbose mode (unless -nopanic flag is used)
2015-10-04 17:08:51 +02:00
Adnan Hajdarevic d4810bebdb Merge branch 'master' into development 2015-10-04 17:06:34 +02:00
Adnan Hajdarevic 272546bb2b add nopanic flag 2015-10-04 17:06:17 +02:00
Adnan Hajdarević 2afc6e6a54 Merge pull request #35 from ciarand/patch-1
Fix reverse build tag in webhook_windows.go
2015-07-23 17:08:10 +02:00
Ciaran Downey a5c92b88a6 Fix reverse build tag in webhook_windows.go
9c545a745f accidentally started negating
the build constraint in webhook_windows.go. This reverses that, fixing
the Windows build.
2015-07-22 14:58:54 -07:00
Adnan Hajdarević d12bbf4036 Update README.md 2015-06-10 09:33:15 +02:00
Adnan Hajdarević d91b34a316 Update README.md 2015-06-10 09:32:07 +02:00
10 changed files with 1007 additions and 445 deletions
+10 -2
View File
@@ -1,4 +1,4 @@
[![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)
[![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)
# 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.
@@ -11,7 +11,8 @@ If you use Slack, you can set up an "Outgoing webhook integration" to run variou
1. receive the request,
2. parse the headers, payload and query variables,
3. check if the specified rules for the hook are satisfied,
3. and finally, pass the specified arguments to the specified command.
3. and finally, pass the specified arguments to the specified command via
command line arguments or via environment variables.
Everything else is the responsibility of the command's author.
@@ -66,6 +67,9 @@ Any form of contribution is welcome and highly appreciated.
Big thanks to [all the current contributors](https://github.com/adnanh/webhook/graphs/contributors) for their contributions!
# Community Contributions
See the [webhook-contrib][wc] repository for a collections of tools and helpers related to [webhook][w] that have been contributed by the [webhook][w] community.
# License
The MIT License (MIT)
@@ -89,3 +93,7 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
[w]: https://github.com/adnanh/webhook
[wc]: https://github.com/adnanh/webhook-contrib
+144 -64
View File
@@ -7,7 +7,6 @@ import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"reflect"
"regexp"
"strconv"
@@ -25,17 +24,77 @@ const (
SourceEntireHeaders string = "entire-headers"
)
const (
// EnvNamespace is the prefix used for passing arguments into the command
// environment.
EnvNamespace string = "HOOK_"
)
// SignatureError describes an invalid payload signature passed to Hook.
type SignatureError struct {
Signature string
}
func (e *SignatureError) Error() string {
if e == nil {
return "<nil>"
}
return fmt.Sprintf("invalid payload signature %s", e.Signature)
}
// ArgumentError describes an invalid argument passed to Hook.
type ArgumentError struct {
Argument Argument
}
func (e *ArgumentError) Error() string {
if e == nil {
return "<nil>"
}
return fmt.Sprintf("couldn't retrieve argument for %+v", e.Argument)
}
// SourceError describes an invalid source passed to Hook.
type SourceError struct {
Argument Argument
}
func (e *SourceError) Error() string {
if e == nil {
return "<nil>"
}
return fmt.Sprintf("invalid source for argument %+v", e.Argument)
}
// ParseError describes an error parsing user input.
type ParseError struct {
Err error
}
func (e *ParseError) Error() string {
if e == nil {
return "<nil>"
}
return e.Err.Error()
}
// CheckPayloadSignature calculates and verifies SHA1 signature of the given payload
func CheckPayloadSignature(payload []byte, secret string, signature string) (string, bool) {
func CheckPayloadSignature(payload []byte, secret string, signature string) (string, error) {
if strings.HasPrefix(signature, "sha1=") {
signature = signature[5:]
}
mac := hmac.New(sha1.New, []byte(secret))
mac.Write(payload)
_, err := mac.Write(payload)
if err != nil {
return "", err
}
expectedMAC := hex.EncodeToString(mac.Sum(nil))
return expectedMAC, hmac.Equal([]byte(signature), []byte(expectedMAC))
if !hmac.Equal([]byte(signature), []byte(expectedMAC)) {
return expectedMAC, &SignatureError{expectedMAC}
}
return expectedMAC, err
}
// ReplaceParameter replaces parameter value with the passed value in the passed map
@@ -136,8 +195,8 @@ func ExtractParameterAsString(s string, params interface{}) (string, bool) {
// Argument type specifies the parameter key name and the source it should
// be extracted from
type Argument struct {
Source string `json:"source"`
Name string `json:"name"`
Source string `json:"source,omitempty"`
Name string `json:"name,omitempty"`
}
// Get Argument method returns the value for the Argument's key name
@@ -189,19 +248,20 @@ func (ha *Argument) Get(headers, query, payload *map[string]interface{}) (string
// Hook type is a structure containing details for a single hook
type Hook struct {
ID string `json:"id"`
ExecuteCommand string `json:"execute-command"`
CommandWorkingDirectory string `json:"command-working-directory"`
ResponseMessage string `json:"response-message"`
CaptureCommandOutput bool `json:"include-command-output-in-response"`
PassArgumentsToCommand []Argument `json:"pass-arguments-to-command"`
JSONStringParameters []Argument `json:"parse-parameters-as-json"`
TriggerRule *Rules `json:"trigger-rule"`
ID string `json:"id,omitempty"`
ExecuteCommand string `json:"execute-command,omitempty"`
CommandWorkingDirectory string `json:"command-working-directory,omitempty"`
ResponseMessage string `json:"response-message,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"`
}
// 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{}) {
func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface{}) error {
for i := range h.JSONStringParameters {
if arg, ok := h.JSONStringParameters[i].Get(headers, query, payload); ok {
var newArg map[string]interface{}
@@ -212,8 +272,9 @@ func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface
err := decoder.Decode(&newArg)
if err != nil {
log.Printf("error parsing argument as JSON payload %+v\n", err)
} else {
return &ParseError{err}
}
var source *map[string]interface{}
switch h.JSONStringParameters[i].Source {
@@ -228,18 +289,19 @@ func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface
if source != nil {
ReplaceParameter(h.JSONStringParameters[i].Name, source, newArg)
} else {
log.Printf("invalid source for argument %+v\n", h.JSONStringParameters[i])
}
return &SourceError{h.JSONStringParameters[i]}
}
} else {
log.Printf("couldn't retrieve argument for %+v\n", h.JSONStringParameters[i])
return &ArgumentError{h.JSONStringParameters[i]}
}
}
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 {
func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]interface{}) ([]string, error) {
var args = make([]string, 0)
args = append(args, h.ExecuteCommand)
@@ -249,11 +311,28 @@ func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]inter
args = append(args, arg)
} else {
args = append(args, "")
log.Printf("couldn't retrieve argument for %+v\n", h.PassArgumentsToCommand[i])
return args, &ArgumentError{h.PassArgumentsToCommand[i]}
}
}
return args
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) {
var args = make([]string, 0)
for i := range h.PassEnvironmentToCommand {
if arg, ok := h.PassEnvironmentToCommand[i].Get(headers, query, payload); ok {
args = append(args, EnvNamespace+h.PassEnvironmentToCommand[i].Name+"="+arg)
} else {
return args, &ArgumentError{h.PassEnvironmentToCommand[i]}
}
}
return args, nil
}
// Hooks is an array of Hook objects
@@ -291,7 +370,7 @@ func (h *Hooks) Match(id string) *Hook {
// MatchAll iterates through Hooks and returns all of the hooks that match the
// given ID, if no hook matches the given ID, nil is returned
func (h *Hooks) MatchAll(id string) []*Hook {
matchedHooks := make([]*Hook, 0)
var matchedHooks []*Hook
for i := range *h {
if (*h)[i].ID == id {
matchedHooks = append(matchedHooks, &(*h)[i])
@@ -307,15 +386,15 @@ func (h *Hooks) MatchAll(id string) []*Hook {
// Rules is a structure that contains one of the valid rule types
type Rules struct {
And *AndRule `json:"and"`
Or *OrRule `json:"or"`
Not *NotRule `json:"not"`
Match *MatchRule `json:"match"`
And *AndRule `json:"and,omitempty"`
Or *OrRule `json:"or,omitempty"`
Not *NotRule `json:"not,omitempty"`
Match *MatchRule `json:"match,omitempty"`
}
// Evaluate finds the first rule property that is not nil and returns the value
// it evaluates to
func (r Rules) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) bool {
func (r Rules) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) {
switch {
case r.And != nil:
return r.And.Evaluate(headers, query, payload, body)
@@ -327,58 +406,69 @@ func (r Rules) Evaluate(headers, query, payload *map[string]interface{}, body *[
return r.Match.Evaluate(headers, query, payload, body)
}
return false
return false, nil
}
// AndRule will evaluate to true if and only if all of the ChildRules evaluate to true
type AndRule []Rules
// Evaluate AndRule will return true if and only if all of ChildRules evaluate to true
func (r AndRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) bool {
func (r AndRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) {
res := true
for _, v := range r {
res = res && v.Evaluate(headers, query, payload, body)
rv, err := v.Evaluate(headers, query, payload, body)
if err != nil {
return false, err
}
res = res && rv
if res == false {
return res
return res, nil
}
}
return res
return res, nil
}
// OrRule will evaluate to true if any of the ChildRules evaluate to true
type OrRule []Rules
// Evaluate OrRule will return true if any of ChildRules evaluate to true
func (r OrRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) bool {
func (r OrRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) {
res := false
for _, v := range r {
res = res || v.Evaluate(headers, query, payload, body)
rv, err := v.Evaluate(headers, query, payload, body)
if err != nil {
return false, err
}
res = res || rv
if res == true {
return res
return res, nil
}
}
return res
return res, nil
}
// NotRule will evaluate to true if any and only if the ChildRule evaluates to false
type NotRule Rules
// Evaluate NotRule will return true if and only if ChildRule evaluates to false
func (r NotRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) bool {
return !Rules(r).Evaluate(headers, query, payload, body)
func (r NotRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) {
rv, err := Rules(r).Evaluate(headers, query, payload, body)
return !rv, err
}
// MatchRule will evaluate to true based on the type
type MatchRule struct {
Type string `json:"type"`
Regex string `json:"regex"`
Secret string `json:"secret"`
Value string `json:"value"`
Parameter Argument `json:"parameter"`
Type string `json:"type,omitempty"`
Regex string `json:"regex,omitempty"`
Secret string `json:"secret,omitempty"`
Value string `json:"value,omitempty"`
Parameter Argument `json:"parameter,omitempty"`
}
// Constants for the MatchRule type
@@ -389,34 +479,24 @@ const (
)
// Evaluate MatchRule will return based on the type
func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) bool {
func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) {
if arg, ok := r.Parameter.Get(headers, query, payload); ok {
switch r.Type {
case MatchValue:
return arg == r.Value
return arg == r.Value, nil
case MatchRegex:
ok, err := regexp.MatchString(r.Regex, arg)
if err != nil {
log.Printf("error while trying to evaluate regex: %+v", err)
}
return ok
return regexp.MatchString(r.Regex, arg)
case MatchHashSHA1:
expected, ok := CheckPayloadSignature(*body, r.Secret, arg)
if !ok {
log.Printf("payload signature mismatch, expected %s got %s", expected, arg)
_, err := CheckPayloadSignature(*body, r.Secret, arg)
return err == nil, err
}
return ok
}
} else {
log.Printf("couldn't retrieve argument for %+v\n", r.Parameter)
}
return false
return false, nil
}
// CommandStatusResponse type encapsulates the executed command exit code, message, stdout and stderr
type CommandStatusResponse struct {
ResponseMessage string `json:"message"`
Output string `json:"output"`
Error string `json:"error"`
ResponseMessage string `json:"message,omitempty"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
}
+71 -48
View File
@@ -21,9 +21,9 @@ var checkPayloadSignatureTests = []struct {
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)
mac, err := CheckPayloadSignature(tt.payload, tt.secret, tt.signature)
if (err == nil) != 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, (err == nil))
}
}
}
@@ -92,23 +92,24 @@ var hookParseJSONParametersTests = []struct {
params []Argument
headers, query, payload *map[string]interface{}
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},
{[]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},
{[]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},
// failures
{[]Argument{Argument{"header", "z"}}, &map[string]interface{}{"z": ``}, nil, nil, &map[string]interface{}{"z": ``}, nil, nil}, // empty string
{[]Argument{Argument{"header", "y"}}, &map[string]interface{}{"X": `{}`}, nil, nil, &map[string]interface{}{"X": `{}`}, nil, nil}, // missing parameter
{[]Argument{Argument{"string", "z"}}, &map[string]interface{}{"z": ``}, nil, nil, &map[string]interface{}{"z": ``}, nil, nil}, // invalid argument source
{[]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
}
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)
err := h.ParseJSONParameters(tt.headers, tt.query, tt.payload)
if (err == nil) != tt.ok || !reflect.DeepEqual(tt.headers, tt.rheaders) {
t.Errorf("failed to parse %v:\nexpected %#v, ok: %v\ngot %#v, ok: %v", tt.params, *tt.rheaders, tt.ok, *tt.headers, (err == nil))
}
}
}
@@ -118,18 +119,19 @@ var hookExtractCommandArgumentsTests = []struct {
args []Argument
headers, query, payload *map[string]interface{}
value []string
ok bool
}{
{"test", []Argument{Argument{"header", "a"}}, &map[string]interface{}{"a": "z"}, nil, nil, []string{"test", "z"}},
{"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", ""}},
{"fail", []Argument{Argument{"payload", "a"}}, &map[string]interface{}{"a": "z"}, nil, nil, []string{"fail", ""}, false},
}
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)
value, err := h.ExtractCommandArguments(tt.headers, tt.query, tt.payload)
if (err == nil) != tt.ok || !reflect.DeepEqual(value, tt.value) {
t.Errorf("failed to extract args {cmd=%q, args=%v}:\nexpected %#v, ok: %v\ngot %#v, ok: %v", tt.exec, tt.args, tt.value, tt.ok, value, (err == nil))
}
}
}
@@ -178,24 +180,26 @@ var matchRuleTests = []struct {
headers, query, payload *map[string]interface{}
body []byte
ok bool
err 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},
{"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},
{"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},
{"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
}
func TestMatchRule(t *testing.T) {
for _, tt := range matchRuleTests {
for i, 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)
ok, err := r.Evaluate(tt.headers, tt.query, tt.payload, &tt.body)
if ok != tt.ok || (err != nil) != tt.err {
t.Errorf("%d failed to match %#v:\nexpected ok: %#v, err: %v\ngot ok: %#v, err: %v", i, r, tt.ok, tt.err, ok, (err != nil))
}
}
}
@@ -206,6 +210,7 @@ var andRuleTests = []struct {
headers, query, payload *map[string]interface{}
body []byte
ok bool
err bool
}{
{
"(a=z, b=y): a=z && b=y",
@@ -214,7 +219,7 @@ var andRuleTests = []struct {
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b"}}},
},
&map[string]interface{}{"a": "z", "b": "y"}, nil, nil, []byte{},
true,
true, false,
},
{
"(a=z, b=Y): a=z && b=y",
@@ -223,7 +228,7 @@ var andRuleTests = []struct {
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b"}}},
},
&map[string]interface{}{"a": "z", "b": "Y"}, nil, nil, []byte{},
false,
false, false,
},
// Complex test to cover Rules.Evaluate
{
@@ -249,16 +254,23 @@ var andRuleTests = []struct {
},
},
&map[string]interface{}{"a": "z", "b": "y", "c": "x", "d": "w", "e": "X", "f": "X"}, nil, nil, []byte{},
true,
true, false,
},
{"empty rule", AndRule{{}}, nil, nil, nil, nil, false, false},
// failures
{
"invalid rule",
AndRule{{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a"}}}},
&map[string]interface{}{"y": "z"}, nil, nil, nil,
false, false,
},
{"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)
ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body)
if ok != tt.ok || (err != nil) != tt.err {
t.Errorf("failed to match %#v:\nexpected ok: %#v, err: %v\ngot ok: %#v, err: %v", tt.desc, tt.ok, tt.err, ok, err)
}
}
}
@@ -269,6 +281,7 @@ var orRuleTests = []struct {
headers, query, payload *map[string]interface{}
body []byte
ok bool
err bool
}{
{
"(a=z, b=X): a=z || b=y",
@@ -277,7 +290,7 @@ var orRuleTests = []struct {
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b"}}},
},
&map[string]interface{}{"a": "z", "b": "X"}, nil, nil, []byte{},
true,
true, false,
},
{
"(a=X, b=y): a=z || b=y",
@@ -286,7 +299,7 @@ var orRuleTests = []struct {
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b"}}},
},
&map[string]interface{}{"a": "X", "b": "y"}, nil, nil, []byte{},
true,
true, false,
},
{
"(a=Z, b=Y): a=z || b=y",
@@ -295,15 +308,24 @@ var orRuleTests = []struct {
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b"}}},
},
&map[string]interface{}{"a": "Z", "b": "Y"}, nil, nil, []byte{},
false,
false, false,
},
// failures
{
"invalid rule",
OrRule{
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a"}}},
},
&map[string]interface{}{"y": "Z"}, nil, nil, []byte{},
false, 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)
ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body)
if ok != tt.ok || (err != nil) != tt.err {
t.Errorf("%#v:\nexpected ok: %#v, err: %v\ngot ok: %#v err: %v", tt.desc, tt.ok, tt.err, ok, err)
}
}
}
@@ -314,16 +336,17 @@ var notRuleTests = []struct {
headers, query, payload *map[string]interface{}
body []byte
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},
{"(a=z): !a=z", NotRule{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a"}}}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, 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) {
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)
ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body)
if ok != tt.ok || (err != nil) != tt.err {
t.Errorf("failed to match %#v:\nexpected ok: %#v, err: %v\ngot ok: %#v, err: %v", tt.rule, tt.ok, tt.err, ok, err)
}
}
}
+34
View File
@@ -0,0 +1,34 @@
// +build !windows
package main
import (
"log"
"os"
"os/signal"
"syscall"
)
func setupSignals() {
log.Printf("setting up os signal watcher\n")
signals = make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGUSR1)
go watchForSignals()
}
func watchForSignals() {
log.Println("os signal watcher ready")
for {
sig := <-signals
if sig == syscall.SIGUSR1 {
log.Println("caught USR1 signal")
reloadHooks()
} else {
log.Printf("caught unhandled signal %+v\n", sig)
}
}
}
+7
View File
@@ -0,0 +1,7 @@
// +build windows
package main
func setupSignals() {
// NOOP: Windows doesn't have signals equivalent to the Unix world.
}
+26
View File
@@ -0,0 +1,26 @@
// Hook Echo is a simply utility used for testing the Webhook package.
package main
import (
"fmt"
"os"
"strings"
)
func main() {
if len(os.Args) > 1 {
fmt.Printf("arg: %s\n", strings.Join(os.Args[1:], " "))
}
var env []string
for _, v := range os.Environ() {
if strings.HasPrefix(v, "HOOK_") {
env = append(env, v)
}
}
if len(env) > 0 {
fmt.Printf("env: %s\n", strings.Join(env, " "))
}
}
+139
View File
@@ -0,0 +1,139 @@
[
{
"id": "github",
"execute-command": "{{ .Hookecho }}",
"command-working-directory": "/",
"include-command-output-in-response": true,
"pass-environment-to-command":
[
{
"source": "payload",
"name": "head_commit.timestamp"
}
],
"pass-arguments-to-command":
[
{
"source": "payload",
"name": "head_commit.id"
},
{
"source": "payload",
"name": "head_commit.author.email"
}
],
"trigger-rule":
{
"and":
[
{
"match":
{
"type": "payload-hash-sha1",
"secret": "mysecret",
"parameter":
{
"source": "header",
"name": "X-Hub-Signature"
}
}
},
{
"match":
{
"type": "value",
"value": "refs/heads/master",
"parameter":
{
"source": "payload",
"name": "ref"
}
}
}
]
}
},
{
"id": "bitbucket",
"execute-command": "{{ .Hookecho }}",
"command-working-directory": "/",
"include-command-output-in-response": true,
"response-message": "success",
"parse-parameters-as-json": [
{
"source": "payload",
"name": "payload"
}
],
"trigger-rule": {
"and": [
{
"match": {
"type": "value",
"parameter": {
"source": "payload",
"name": "payload.canon_url"
},
"value": "https://bitbucket.org"
}
},
{
"match": {
"type": "value",
"parameter": {
"source": "payload",
"name": "payload.repository.absolute_url"
},
"value": "/webhook/testing/"
}
},
{
"match": {
"type": "value",
"parameter": {
"source": "payload",
"name": "payload.commits.0.branch"
},
"value": "master"
}
}
]
}
},
{
"id": "gitlab",
"execute-command": "{{ .Hookecho }}",
"command-working-directory": "/",
"response-message": "success",
"include-command-output-in-response": true,
"pass-arguments-to-command":
[
{
"source": "payload",
"name": "commits.0.id"
},
{
"source": "payload",
"name": "user_name"
},
{
"source": "payload",
"name": "user_email"
}
],
"trigger-rule":
{
"match":
{
"type": "value",
"value": "refs/heads/master",
"parameter":
{
"source": "payload",
"name": "ref"
}
}
}
}
]
+50 -35
View File
@@ -1,5 +1,3 @@
//+build !windows
package main
import (
@@ -12,9 +10,7 @@ import (
"net/url"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"github.com/adnanh/webhook/hook"
@@ -25,13 +21,14 @@ import (
)
const (
version = "2.3.4"
version = "2.3.7"
)
var (
ip = flag.String("ip", "", "ip the webhook should serve hooks on")
port = flag.Int("port", 9000, "port the webhook should serve hooks on")
verbose = flag.Bool("verbose", false, "show verbose output")
noPanic = flag.Bool("nopanic", false, "do not panic if hooks cannot be loaded when webhook is not running in verbose mode")
hotReload = flag.Bool("hotreload", false, "watch hooks file for changes and reload them automatically")
hooksFilePath = flag.String("hooks", "hooks.json", "path to the json file containing defined hooks the webhook should serve")
hooksURLPrefix = flag.String("urlprefix", "hooks", "url prefix to use for served hooks (protocol://yourserver:port/PREFIX/:hook-id)")
@@ -45,7 +42,7 @@ var (
hooks hook.Hooks
)
func init() {
func main() {
hooks = hook.Hooks{}
flag.Parse()
@@ -60,12 +57,7 @@ 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.SIGUSR1)
go watchForSignals()
setupSignals()
// load and parse hooks
log.Printf("attempting to load hooks from %s\n", *hooksFilePath)
@@ -73,6 +65,11 @@ func init() {
err := hooks.LoadFromFile(*hooksFilePath)
if err != nil {
if !*verbose && !*noPanic {
log.SetOutput(os.Stdout)
log.Fatalf("couldn't load any hooks from file! %+v\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to start without the hooks, either use -verbose flag, or -nopanic", err)
}
log.Printf("couldn't load hooks from file! %+v\n", err)
} else {
log.Printf("loaded %d hook(s) from file\n", len(hooks))
@@ -81,9 +78,7 @@ func init() {
log.Printf("\t> %s\n", hook.ID)
}
}
}
func main() {
if *hotReload {
// set up file watcher
log.Printf("setting up file watcher for %s\n", *hooksFilePath)
@@ -106,7 +101,7 @@ func main() {
}
l := negroni.NewLogger()
l.Logger = log.New(os.Stdout, "[webhook] ", log.Ldate|log.Ltime)
l.Logger = log.New(os.Stderr, "[webhook] ", log.Ldate|log.Ltime)
negroniRecovery := &negroni.Recovery{
Logger: l.Logger,
@@ -185,8 +180,31 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
// handle hook
for _, h := range matchedHooks {
h.ParseJSONParameters(&headers, &query, &payload)
if h.TriggerRule == nil || h.TriggerRule != nil && h.TriggerRule.Evaluate(&headers, &query, &payload, &body) {
err := h.ParseJSONParameters(&headers, &query, &payload)
if err != nil {
msg := fmt.Sprintf("error parsing JSON: %s", err)
log.Printf(msg)
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, msg)
return
}
var ok bool
if h.TriggerRule == nil {
ok = true
} else {
ok, err = h.TriggerRule.Evaluate(&headers, &query, &payload, &body)
if err != nil {
msg := fmt.Sprintf("error evaluating hook: %s", err)
log.Printf(msg)
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, msg)
return
}
}
if ok {
log.Printf("%s hook triggered successfully\n", h.ID)
if h.CaptureCommandOutput {
@@ -204,7 +222,6 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
// if none of the hooks got triggered
log.Printf("%s got matched (%d time(s)), but didn't get triggered because the trigger rules were not satisfied\n", matchedHooks[0].ID, len(matchedHooks))
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "Hook rules were not satisfied.")
} else {
w.WriteHeader(http.StatusNotFound)
@@ -213,11 +230,24 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
}
func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, body *[]byte) string {
var err error
cmd := exec.Command(h.ExecuteCommand)
cmd.Args = h.ExtractCommandArguments(headers, query, payload)
cmd.Dir = h.CommandWorkingDirectory
log.Printf("executing %s (%s) with arguments %s using %s as cwd\n", h.ExecuteCommand, cmd.Path, cmd.Args, cmd.Dir)
cmd.Args, err = h.ExtractCommandArguments(headers, query, payload)
if err != nil {
log.Printf("error extracting command arguments: %s", err)
}
var envs []string
envs, err = h.ExtractCommandArgumentsForEnv(headers, query, payload)
if err != nil {
log.Printf("error extracting command arguments for environment: %s", 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)
out, err := cmd.CombinedOutput()
@@ -279,21 +309,6 @@ func watchForFileChange() {
}
}
func watchForSignals() {
log.Println("os signal watcher ready")
for {
sig := <-signals
if sig == syscall.SIGUSR1 {
log.Println("caught USR1 signal")
reloadHooks()
} else {
log.Printf("caught unhandled signal %+v\n", sig)
}
}
}
// valuesToMap converts map[string][]string to a map[string]string object
func valuesToMap(values map[string][]string) map[string]interface{} {
ret := make(map[string]interface{})
+513
View File
@@ -0,0 +1,513 @@
package main
import (
"fmt"
"io/ioutil"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"text/template"
"time"
"github.com/adnanh/webhook/hook"
)
func TestWebhook(t *testing.T) {
hookecho, cleanupHookecho := buildHookecho(t)
defer cleanupHookecho()
config, cleanupConfig := genConfig(t, hookecho)
defer cleanupConfig()
webhook, cleanupWebhook := buildWebhook(t)
defer cleanupWebhook()
ip, port := serverAddress(t)
args := []string{fmt.Sprintf("-hooks=%s", config), fmt.Sprintf("-ip=%s", ip), fmt.Sprintf("-port=%s", port), "-verbose"}
cmd := exec.Command(webhook, args...)
//cmd.Stderr = os.Stderr // uncomment to see verbose output
cmd.Env = webhookEnv()
cmd.Args[0] = "webhook"
if err := cmd.Start(); err != nil {
t.Fatalf("failed to start webhook: %s", err)
}
defer killAndWait(cmd)
waitForServerReady(t, ip, port)
for _, tt := range hookHandlerTests {
url := fmt.Sprintf("http://%s:%s/hooks/%s", ip, port, tt.id)
req, err := http.NewRequest("POST", url, ioutil.NopCloser(strings.NewReader(tt.body)))
if err != nil {
t.Errorf("New request failed: %s", err)
}
for k, v := range tt.headers {
req.Header.Add(k, v)
}
var res *http.Response
if tt.urlencoded == true {
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
} else {
req.Header.Add("Content-Type", "application/json")
}
client := &http.Client{}
res, err = client.Do(req)
if err != nil {
t.Errorf("client.Do failed: %s", err)
}
body, err := ioutil.ReadAll(res.Body)
res.Body.Close()
if err != nil {
t.Errorf("POST %q: failed to ready body: %s", tt.desc, err)
}
if res.StatusCode != tt.respStatus || string(body) != tt.respBody {
t.Errorf("failed %q (id: %s):\nexpected status: %#v, response: %s\ngot status: %#v, response: %s", tt.desc, tt.id, tt.respStatus, tt.respBody, res.StatusCode, body)
}
}
}
func buildHookecho(t *testing.T) (bin string, cleanup func()) {
tmp, err := ioutil.TempDir("", "hookecho-test-")
if err != nil {
t.Fatal(err)
}
defer func() {
if cleanup == nil {
os.RemoveAll(tmp)
}
}()
bin = filepath.Join(tmp, "hookecho")
if runtime.GOOS == "windows" {
bin += ".exe"
}
cmd := exec.Command("go", "build", "-o", bin, "test/hookecho.go")
if err := cmd.Run(); err != nil {
t.Fatalf("Building hookecho: %v", err)
}
return bin, func() { os.RemoveAll(tmp) }
}
func genConfig(t *testing.T, bin string) (config string, cleanup func()) {
tmpl := template.Must(template.ParseFiles("test/hooks.json.tmpl"))
tmp, err := ioutil.TempDir("", "webhook-config-")
if err != nil {
t.Fatal(err)
}
defer func() {
if cleanup == nil {
os.RemoveAll(tmp)
}
}()
path := filepath.Join(tmp, "hooks.json")
file, err := os.Create(path)
if err != nil {
t.Fatalf("Creating config template: %v", err)
}
defer file.Close()
data := struct{ Hookecho string }{filepath.ToSlash(bin)}
if err := tmpl.Execute(file, data); err != nil {
t.Fatalf("Executing template: %v", err)
}
return path, func() { os.RemoveAll(tmp) }
}
func buildWebhook(t *testing.T) (bin string, cleanup func()) {
tmp, err := ioutil.TempDir("", "webhook-test-")
if err != nil {
t.Fatal(err)
}
defer func() {
if cleanup == nil {
os.RemoveAll(tmp)
}
}()
bin = filepath.Join(tmp, "webhook")
if runtime.GOOS == "windows" {
bin += ".exe"
}
cmd := exec.Command("go", "build", "-o", bin)
if err := cmd.Run(); err != nil {
t.Fatalf("Building webhook: %v", err)
}
return bin, func() { os.RemoveAll(tmp) }
}
func serverAddress(t *testing.T) (string, string) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
ln, err = net.Listen("tcp6", "[::1]:0")
}
if err != nil {
t.Fatal(err)
}
defer ln.Close()
host, port, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
t.Fatalf("Failed to split network address: %v", err)
}
return host, port
}
func waitForServerReady(t *testing.T, ip, port string) {
waitForServer(t,
fmt.Sprintf("http://%v:%v/", ip, port),
http.StatusNotFound,
5*time.Second)
}
const pollInterval = 200 * time.Millisecond
func waitForServer(t *testing.T, url string, status int, timeout time.Duration) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
time.Sleep(pollInterval)
res, err := http.Get(url)
if err != nil {
continue
}
if res.StatusCode == status {
return
}
}
t.Fatalf("Server failed to respond in %v", timeout)
}
func killAndWait(cmd *exec.Cmd) {
cmd.Process.Kill()
cmd.Wait()
}
// webhookEnv returns the process environment without any existing hook
// namespace variables.
func webhookEnv() (env []string) {
for _, v := range os.Environ() {
if strings.HasPrefix(v, hook.EnvNamespace) {
continue
}
env = append(env, v)
}
return
}
var hookHandlerTests = []struct {
desc string
id string
headers map[string]string
body string
urlencoded bool
respStatus int
respBody string
}{
{
"github",
"github",
map[string]string{"X-Hub-Signature": "f68df0375d7b03e3eb29b4cf9f9ec12e08f42ff8"},
`{
"after":"1481a2de7b2a7d02428ad93446ab166be7793fbb",
"before":"17c497ccc7cca9c2f735aa07e9e3813060ce9a6a",
"commits":[
{
"added":[
],
"author":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"committer":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"distinct":true,
"id":"c441029cf673f84c8b7db52d0a5944ee5c52ff89",
"message":"Test",
"modified":[
"README.md"
],
"removed":[
],
"timestamp":"2013-02-22T13:50:07-08:00",
"url":"https://github.com/octokitty/testing/commit/c441029cf673f84c8b7db52d0a5944ee5c52ff89"
},
{
"added":[
],
"author":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"committer":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"distinct":true,
"id":"36c5f2243ed24de58284a96f2a643bed8c028658",
"message":"This is me testing the windows client.",
"modified":[
"README.md"
],
"removed":[
],
"timestamp":"2013-02-22T14:07:13-08:00",
"url":"https://github.com/octokitty/testing/commit/36c5f2243ed24de58284a96f2a643bed8c028658"
},
{
"added":[
"words/madame-bovary.txt"
],
"author":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"committer":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"distinct":true,
"id":"1481a2de7b2a7d02428ad93446ab166be7793fbb",
"message":"Rename madame-bovary.txt to words/madame-bovary.txt",
"modified":[
],
"removed":[
"madame-bovary.txt"
],
"timestamp":"2013-03-12T08:14:29-07:00",
"url":"https://github.com/octokitty/testing/commit/1481a2de7b2a7d02428ad93446ab166be7793fbb"
}
],
"compare":"https://github.com/octokitty/testing/compare/17c497ccc7cc...1481a2de7b2a",
"created":false,
"deleted":false,
"forced":false,
"head_commit":{
"added":[
"words/madame-bovary.txt"
],
"author":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"committer":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"distinct":true,
"id":"1481a2de7b2a7d02428ad93446ab166be7793fbb",
"message":"Rename madame-bovary.txt to words/madame-bovary.txt",
"modified":[
],
"removed":[
"madame-bovary.txt"
],
"timestamp":"2013-03-12T08:14:29-07:00",
"url":"https://github.com/octokitty/testing/commit/1481a2de7b2a7d02428ad93446ab166be7793fbb"
},
"pusher":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian"
},
"ref":"refs/heads/master",
"repository":{
"created_at":1332977768,
"description":"",
"fork":false,
"forks":0,
"has_downloads":true,
"has_issues":true,
"has_wiki":true,
"homepage":"",
"id":3860742,
"language":"Ruby",
"master_branch":"master",
"name":"testing",
"open_issues":2,
"owner":{
"email":"lolwut@noway.biz",
"name":"octokitty"
},
"private":false,
"pushed_at":1363295520,
"size":2156,
"stargazers":1,
"url":"https://github.com/octokitty/testing",
"watchers":1
}
}`,
false,
http.StatusOK,
`{"output":"arg: 1481a2de7b2a7d02428ad93446ab166be7793fbb lolwut@noway.biz\nenv: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00\n"}`,
},
{
"bitbucket", // bitbucket sends their payload using uriencoded params.
"bitbucket",
nil,
`payload={"canon_url": "https://bitbucket.org","commits": [{"author": "marcus","branch": "master","files": [{"file": "somefile.py","type": "modified"}],"message": "Added some more things to somefile.py\n","node": "620ade18607a","parents": ["702c70160afc"],"raw_author": "Marcus Bertrand <marcus@somedomain.com>","raw_node": "620ade18607ac42d872b568bb92acaa9a28620e9","revision": null,"size": -1,"timestamp": "2012-05-30 05:58:56","utctimestamp": "2014-11-07 15:19:02+00:00"}],"repository": {"absolute_url": "/webhook/testing/","fork": false,"is_private": true,"name": "Project X","owner": "marcus","scm": "git","slug": "project-x","website": "https://atlassian.com/"},"user": "marcus"}`,
true,
http.StatusOK,
`{"message":"success"}`,
},
{
"gitlab",
"gitlab",
map[string]string{"X-Gitlab-Event": "Push Hook"},
`{
"object_kind": "push",
"before": "95790bf891e76fee5e1747ab589903a6a1f80f22",
"after": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
"ref": "refs/heads/master",
"user_id": 4,
"user_name": "John Smith",
"user_email": "john@example.com",
"project_id": 15,
"repository": {
"name": "Diaspora",
"url": "git@example.com:mike/diasporadiaspora.git",
"description": "",
"homepage": "http://example.com/mike/diaspora",
"git_http_url":"http://example.com/mike/diaspora.git",
"git_ssh_url":"git@example.com:mike/diaspora.git",
"visibility_level":0
},
"commits": [
{
"id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327",
"message": "Update Catalan translation to e38cb41.",
"timestamp": "2011-12-12T14:27:31+02:00",
"url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327",
"author": {
"name": "Jordi Mallach",
"email": "jordi@softcatala.org"
}
},
{
"id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
"message": "fixed readme",
"timestamp": "2012-01-03T23:36:29+02:00",
"url": "http://example.com/mike/diaspora/commit/da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
"author": {
"name": "GitLab dev user",
"email": "gitlabdev@dv6700.(none)"
}
}
],
"total_commits_count": 4
}`,
false,
http.StatusOK,
`{"message":"success","output":"arg: b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327 John Smith john@example.com\n"}`,
},
{
"missing-cmd-arg", // missing head_commit.author.email
"github",
map[string]string{"X-Hub-Signature": "ab03955b9377f530aa298b1b6d273ae9a47e1e40"},
`{
"head_commit":{
"added":[
"words/madame-bovary.txt"
],
"author":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"committer":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"distinct":true,
"id":"1481a2de7b2a7d02428ad93446ab166be7793fbb",
"message":"Rename madame-bovary.txt to words/madame-bovary.txt",
"modified":[
],
"removed":[
"madame-bovary.txt"
],
"timestamp":"2013-03-12T08:14:29-07:00",
"url":"https://github.com/octokitty/testing/commit/1481a2de7b2a7d02428ad93446ab166be7793fbb"
},
"ref":"refs/heads/master"
}`,
false,
http.StatusOK,
`{"output":"arg: 1481a2de7b2a7d02428ad93446ab166be7793fbb lolwut@noway.biz\nenv: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00\n"}`,
},
{
"missing-env-arg", // missing head_commit.timestamp
"github",
map[string]string{"X-Hub-Signature": "2cf8b878cb6b74a25090a140fa4a474be04b97fa"},
`{
"head_commit":{
"added":[
"words/madame-bovary.txt"
],
"author":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"committer":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"distinct":true,
"id":"1481a2de7b2a7d02428ad93446ab166be7793fbb",
"message":"Rename madame-bovary.txt to words/madame-bovary.txt",
"modified":[
],
"removed":[
"madame-bovary.txt"
],
"url":"https://github.com/octokitty/testing/commit/1481a2de7b2a7d02428ad93446ab166be7793fbb"
},
"ref":"refs/heads/master"
}`,
false,
http.StatusOK,
`{"output":"arg: 1481a2de7b2a7d02428ad93446ab166be7793fbb lolwut@noway.biz\n"}`,
},
{"empty payload", "github", nil, `{}`, false, http.StatusOK, `Hook rules were not satisfied.`},
}
-283
View File
@@ -1,283 +0,0 @@
//+build !windows
package main
import (
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"strings"
"github.com/adnanh/webhook/hook"
"github.com/codegangsta/negroni"
"github.com/gorilla/mux"
fsnotify "gopkg.in/fsnotify.v1"
)
const (
version = "2.3.4"
)
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")
hooksURLPrefix = flag.String("urlprefix", "hooks", "url prefix to use for served hooks (protocol://yourserver:port/PREFIX/:hook-id)")
secure = flag.Bool("secure", false, "use HTTPS instead of HTTP")
cert = flag.String("cert", "cert.pem", "path to the HTTPS certificate pem file")
key = flag.String("key", "key.pem", "path to the HTTPS certificate private key pem file")
watcher *fsnotify.Watcher
signals chan os.Signal
hooks hook.Hooks
)
func init() {
hooks = hook.Hooks{}
flag.Parse()
log.SetPrefix("[webhook] ")
log.SetFlags(log.Ldate | log.Ltime)
if !*verbose {
log.SetOutput(ioutil.Discard)
}
log.Println("version " + version + " starting")
// load and parse hooks
log.Printf("attempting to load hooks from %s\n", *hooksFilePath)
err := hooks.LoadFromFile(*hooksFilePath)
if err != nil {
log.Printf("couldn't load hooks from file! %+v\n", err)
} else {
log.Printf("loaded %d hook(s) from file\n", len(hooks))
for _, hook := range hooks {
log.Printf("\t> %s\n", hook.ID)
}
}
}
func main() {
if *hotReload {
// set up file watcher
log.Printf("setting up file watcher for %s\n", *hooksFilePath)
var err error
watcher, err = fsnotify.NewWatcher()
if err != nil {
log.Fatal("error creating file watcher instance", err)
}
defer watcher.Close()
go watchForFileChange()
err = watcher.Add(*hooksFilePath)
if err != nil {
log.Fatal("error adding hooks file to the watcher", err)
}
}
l := negroni.NewLogger()
l.Logger = log.New(os.Stdout, "[webhook] ", log.Ldate|log.Ltime)
negroniRecovery := &negroni.Recovery{
Logger: l.Logger,
PrintStack: true,
StackAll: false,
StackSize: 1024 * 8,
}
n := negroni.New(negroniRecovery, l)
router := mux.NewRouter()
var hooksURL string
if *hooksURLPrefix == "" {
hooksURL = "/{id}"
} else {
hooksURL = "/" + *hooksURLPrefix + "/{id}"
}
router.HandleFunc(hooksURL, hookHandler)
n.UseHandler(router)
if *secure {
log.Printf("starting secure (https) webhook on %s:%d", *ip, *port)
log.Fatal(http.ListenAndServeTLS(fmt.Sprintf("%s:%d", *ip, *port), *cert, *key, n))
} else {
log.Printf("starting insecure (http) webhook on %s:%d", *ip, *port)
log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%d", *ip, *port), n))
}
}
func hookHandler(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
matchedHooks := hooks.MatchAll(id)
if matchedHooks != nil {
log.Printf("%s got matched (%d time(s))\n", id, len(matchedHooks))
body, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Printf("error reading the request body. %+v\n", err)
}
// parse headers
headers := valuesToMap(r.Header)
// parse query variables
query := valuesToMap(r.URL.Query())
// parse body
var payload map[string]interface{}
contentType := r.Header.Get("Content-Type")
if strings.Contains(contentType, "json") {
decoder := json.NewDecoder(strings.NewReader(string(body)))
decoder.UseNumber()
err := decoder.Decode(&payload)
if err != nil {
log.Printf("error parsing JSON payload %+v\n", err)
}
} else if strings.Contains(contentType, "form") {
fd, err := url.ParseQuery(string(body))
if err != nil {
log.Printf("error parsing form payload %+v\n", err)
} else {
payload = valuesToMap(fd)
}
}
// handle hook
for _, h := range matchedHooks {
h.ParseJSONParameters(&headers, &query, &payload)
if h.TriggerRule == nil || h.TriggerRule != nil && h.TriggerRule.Evaluate(&headers, &query, &payload, &body) {
log.Printf("%s hook triggered successfully\n", h.ID)
if h.CaptureCommandOutput {
response := handleHook(h, &headers, &query, &payload, &body)
fmt.Fprintf(w, response)
} else {
go handleHook(h, &headers, &query, &payload, &body)
fmt.Fprintf(w, h.ResponseMessage)
}
return
}
}
// if none of the hooks got triggered
log.Printf("%s got matched (%d time(s)), but didn't get triggered because the trigger rules were not satisfied\n", matchedHooks[0].ID, len(matchedHooks))
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "Hook rules were not satisfied.")
} else {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "Hook not found.")
}
}
func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, body *[]byte) string {
cmd := exec.Command(h.ExecuteCommand)
cmd.Args = h.ExtractCommandArguments(headers, query, payload)
cmd.Dir = h.CommandWorkingDirectory
log.Printf("executing %s (%s) with arguments %s using %s as cwd\n", h.ExecuteCommand, cmd.Path, cmd.Args, cmd.Dir)
out, err := cmd.CombinedOutput()
log.Printf("command output: %s\n", out)
var errorResponse string
if err != nil {
log.Printf("error occurred: %+v\n", err)
errorResponse = fmt.Sprintf("%+v", err)
}
log.Printf("finished handling %s\n", h.ID)
var response []byte
response, err = json.Marshal(&hook.CommandStatusResponse{ResponseMessage: h.ResponseMessage, Output: string(out), Error: errorResponse})
if err != nil {
log.Printf("error marshalling response: %+v", err)
return h.ResponseMessage
}
return string(response)
}
func reloadHooks() {
newHooks := hook.Hooks{}
// parse and swap
log.Printf("attempting to reload hooks from %s\n", *hooksFilePath)
err := newHooks.LoadFromFile(*hooksFilePath)
if err != nil {
log.Printf("couldn't load hooks from file! %+v\n", err)
} else {
log.Printf("loaded %d hook(s) from file\n", len(hooks))
for _, hook := range hooks {
log.Printf("\t> %s\n", hook.ID)
}
hooks = newHooks
}
}
func watchForFileChange() {
for {
select {
case event := <-(*watcher).Events:
if event.Op&fsnotify.Write == fsnotify.Write {
log.Println("hooks file modified")
reloadHooks()
}
case err := <-(*watcher).Errors:
log.Println("watcher error:", err)
}
}
}
// valuesToMap converts map[string][]string to a map[string]string object
func valuesToMap(values map[string][]string) map[string]interface{} {
ret := make(map[string]interface{})
for key, value := range values {
if len(value) > 0 {
ret[key] = value[0]
}
}
return ret
}