mirror of
https://github.com/adnanh/webhook.git
synced 2025-05-11 16:14:52 +00:00
commit
a7aa7f2327
12 changed files with 910 additions and 462 deletions
14
Dockerfile
14
Dockerfile
|
@ -1,14 +0,0 @@
|
|||
FROM alpine
|
||||
MAINTAINER Adnan Hajdarevic <adnanh@gmail.com>
|
||||
|
||||
ENV GOPATH /go
|
||||
ENV SRCPATH ${GOPATH}/src/github.com/adnanh/webhook
|
||||
COPY . ${SRCPATH}
|
||||
RUN apk add --update -t build-deps go git libc-dev gcc libgcc && \
|
||||
cd ${SRCPATH} && go get -d && go build -o /usr/local/bin/webhook && \
|
||||
apk del --purge build-deps && \
|
||||
rm -rf /var/cache/apk/* && \
|
||||
rm -rf ${GOPATH}
|
||||
|
||||
EXPOSE 9000
|
||||
ENTRYPOINT ["/usr/local/bin/webhook"]
|
10
Makefile
10
Makefile
|
@ -1,10 +0,0 @@
|
|||
DOCKER_IMAGE_NAME=adnanh/webhook
|
||||
CONTAINER_NAME=webhook
|
||||
|
||||
docker-build: Dockerfile
|
||||
docker build --force-rm=true --tag=${DOCKER_IMAGE_NAME} .
|
||||
|
||||
docker-run:
|
||||
@echo "Here's an example command on how to run a webhook container:"
|
||||
@echo "docker run -d -p 9000:9000 -v /etc/webhook:/etc/webhook --name=${CONTAINER_NAME} \\"
|
||||
@echo " ${DOCKER_IMAGE_NAME} -verbose -hooks=/etc/webhook/hooks.json -hotreload"
|
10
README.md
10
README.md
|
@ -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
|
||||
|
|
205
hook/hook.go
205
hook/hook.go
|
@ -5,9 +5,9 @@ import (
|
|||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
@ -25,17 +25,68 @@ const (
|
|||
SourceEntireHeaders string = "entire-headers"
|
||||
)
|
||||
|
||||
const (
|
||||
// EnvNamespace is the prefix used for passing arguments into the command
|
||||
// environment.
|
||||
EnvNamespace string = "HOOK_"
|
||||
)
|
||||
|
||||
// ErrInvalidPayloadSignature describes an invalid payload signature.
|
||||
var ErrInvalidPayloadSignature = errors.New("invalid payload 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)) {
|
||||
err = ErrInvalidPayloadSignature
|
||||
}
|
||||
return expectedMAC, err
|
||||
}
|
||||
|
||||
// ReplaceParameter replaces parameter value with the passed value in the passed map
|
||||
|
@ -189,19 +240,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"`
|
||||
ExecuteCommand string `json:"execute-command"`
|
||||
CommandWorkingDirectory string `json:"command-working-directory"`
|
||||
ResponseMessage string `json:"response-message"`
|
||||
CaptureCommandOutput bool `json:"include-command-output-in-response"`
|
||||
PassEnvironmentToCommand []Argument `json:"pass-environment-to-command"`
|
||||
PassArgumentsToCommand []Argument `json:"pass-arguments-to-command"`
|
||||
JSONStringParameters []Argument `json:"parse-parameters-as-json"`
|
||||
TriggerRule *Rules `json:"trigger-rule"`
|
||||
}
|
||||
|
||||
// ParseJSONParameters decodes specified arguments to JSON objects and replaces the
|
||||
// string with the newly created object
|
||||
func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface{}) {
|
||||
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,34 +264,36 @@ 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)
|
||||
return &ParseError{err}
|
||||
}
|
||||
|
||||
var source *map[string]interface{}
|
||||
|
||||
switch h.JSONStringParameters[i].Source {
|
||||
case SourceHeader:
|
||||
source = headers
|
||||
case SourcePayload:
|
||||
source = payload
|
||||
case SourceQuery:
|
||||
source = query
|
||||
}
|
||||
|
||||
if source != nil {
|
||||
ReplaceParameter(h.JSONStringParameters[i].Name, source, newArg)
|
||||
} else {
|
||||
var source *map[string]interface{}
|
||||
|
||||
switch h.JSONStringParameters[i].Source {
|
||||
case SourceHeader:
|
||||
source = headers
|
||||
case SourcePayload:
|
||||
source = payload
|
||||
case SourceQuery:
|
||||
source = query
|
||||
}
|
||||
|
||||
if source != nil {
|
||||
ReplaceParameter(h.JSONStringParameters[i].Name, source, newArg)
|
||||
} else {
|
||||
log.Printf("invalid source for argument %+v\n", h.JSONStringParameters[i])
|
||||
}
|
||||
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)
|
||||
|
@ -248,12 +302,28 @@ func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]inter
|
|||
if arg, ok := h.PassArgumentsToCommand[i].Get(headers, query, payload); ok {
|
||||
args = append(args, arg)
|
||||
} else {
|
||||
args = append(args, "")
|
||||
log.Printf("couldn't retrieve argument for %+v\n", h.PassArgumentsToCommand[i])
|
||||
return args, &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 +361,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])
|
||||
|
@ -315,7 +385,7 @@ type Rules struct {
|
|||
|
||||
// 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,49 +397,60 @@ 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
|
||||
|
@ -389,34 +470,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)
|
||||
}
|
||||
|
||||
return ok
|
||||
_, err := CheckPayloadSignature(*body, r.Secret, arg)
|
||||
return err == nil, err
|
||||
}
|
||||
} 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"`
|
||||
}
|
||||
|
|
|
@ -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
signals.go
Normal file
34
signals.go
Normal 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
signals_windows.go
Normal file
7
signals_windows.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
// +build windows
|
||||
|
||||
package main
|
||||
|
||||
func setupSignals() {
|
||||
// NOOP: Windows doesn't have signals equivalent to the Unix world.
|
||||
}
|
26
test/hookecho.go
Normal file
26
test/hookecho.go
Normal 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, " "))
|
||||
}
|
||||
}
|
143
test/hooks.json.tmpl
Normal file
143
test/hooks.json.tmpl
Normal file
|
@ -0,0 +1,143 @@
|
|||
[
|
||||
{
|
||||
"id": "github",
|
||||
"execute-command": "{{ .Hookecho }}",
|
||||
"command-working-directory": "/",
|
||||
"include-command-output-in-response": true,
|
||||
"pass-environment-to-command":
|
||||
[
|
||||
{
|
||||
"source": "payload",
|
||||
"name": "pusher.email"
|
||||
}
|
||||
],
|
||||
"pass-arguments-to-command":
|
||||
[
|
||||
{
|
||||
"source": "payload",
|
||||
"name": "head_commit.id"
|
||||
},
|
||||
{
|
||||
"source": "payload",
|
||||
"name": "pusher.name"
|
||||
},
|
||||
{
|
||||
"source": "payload",
|
||||
"name": "pusher.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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
79
webhook.go
79
webhook.go
|
@ -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,7 +21,7 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
version = "2.3.5"
|
||||
version = "2.3.6"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -46,7 +42,7 @@ var (
|
|||
hooks hook.Hooks
|
||||
)
|
||||
|
||||
func init() {
|
||||
func main() {
|
||||
hooks = hook.Hooks{}
|
||||
|
||||
flag.Parse()
|
||||
|
@ -61,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)
|
||||
|
@ -87,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)
|
||||
|
@ -112,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,
|
||||
|
@ -191,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 {
|
||||
|
@ -210,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)
|
||||
|
@ -219,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)
|
||||
return ""
|
||||
}
|
||||
|
||||
cmd.Env, err = h.ExtractCommandArgumentsForEnv(headers, query, payload)
|
||||
if err != nil {
|
||||
log.Printf("error extracting command arguments: %s", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
log.Printf("executing %s (%s) with arguments %s and environment %s using %s as cwd\n", h.ExecuteCommand, cmd.Path, cmd.Args, cmd.Env, cmd.Dir)
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
|
@ -285,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{})
|
||||
|
|
438
webhook_test.go
Normal file
438
webhook_test.go
Normal file
|
@ -0,0 +1,438 @@
|
|||
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 Garen Torikian lolwut@noway.biz\nenv: HOOK_pusher.email=lolwut@noway.biz\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"}`,
|
||||
},
|
||||
|
||||
{"empty payload", "github", nil, `{}`, false, http.StatusOK, `Hook rules were not satisfied.`},
|
||||
}
|
|
@ -1,287 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/adnanh/webhook/hook"
|
||||
|
||||
"github.com/codegangsta/negroni"
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
fsnotify "gopkg.in/fsnotify.v1"
|
||||
)
|
||||
|
||||
const (
|
||||
version = "2.3.5"
|
||||
)
|
||||
|
||||
var (
|
||||
ip = flag.String("ip", "", "ip the webhook should serve hooks on")
|
||||
port = flag.Int("port", 9000, "port the webhook should serve hooks on")
|
||||
verbose = flag.Bool("verbose", false, "show verbose output")
|
||||
noPanic = flag.Bool("nopanic", false, "do not panic if hooks cannot be loaded when webhook is not running in verbose mode")
|
||||
hotReload = flag.Bool("hotreload", false, "watch hooks file for changes and reload them automatically")
|
||||
hooksFilePath = flag.String("hooks", "hooks.json", "path to the json file containing defined hooks the webhook should serve")
|
||||
hooksURLPrefix = flag.String("urlprefix", "hooks", "url prefix to use for served hooks (protocol://yourserver:port/PREFIX/:hook-id)")
|
||||
secure = flag.Bool("secure", false, "use HTTPS instead of HTTP")
|
||||
cert = flag.String("cert", "cert.pem", "path to the HTTPS certificate pem file")
|
||||
key = flag.String("key", "key.pem", "path to the HTTPS certificate private key pem file")
|
||||
|
||||
watcher *fsnotify.Watcher
|
||||
signals chan os.Signal
|
||||
|
||||
hooks hook.Hooks
|
||||
)
|
||||
|
||||
func init() {
|
||||
hooks = hook.Hooks{}
|
||||
|
||||
flag.Parse()
|
||||
|
||||
log.SetPrefix("[webhook] ")
|
||||
log.SetFlags(log.Ldate | log.Ltime)
|
||||
|
||||
if !*verbose {
|
||||
log.SetOutput(ioutil.Discard)
|
||||
}
|
||||
|
||||
log.Println("version " + version + " starting")
|
||||
|
||||
// load and parse hooks
|
||||
log.Printf("attempting to load hooks from %s\n", *hooksFilePath)
|
||||
|
||||
err := hooks.LoadFromFile(*hooksFilePath)
|
||||
|
||||
if err != nil {
|
||||
if !*verbose && !*noPanic {
|
||||
log.SetOutput(os.Stdout)
|
||||
log.Fatalf("couldn't load any hooks from file! %+v\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to start without the hooks, either use -verbose flag, or -nopanic", err)
|
||||
}
|
||||
|
||||
log.Printf("couldn't load hooks from file! %+v\n", err)
|
||||
} else {
|
||||
log.Printf("loaded %d hook(s) from file\n", len(hooks))
|
||||
|
||||
for _, hook := range hooks {
|
||||
log.Printf("\t> %s\n", hook.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
if *hotReload {
|
||||
// set up file watcher
|
||||
log.Printf("setting up file watcher for %s\n", *hooksFilePath)
|
||||
|
||||
var err error
|
||||
|
||||
watcher, err = fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log.Fatal("error creating file watcher instance", err)
|
||||
}
|
||||
|
||||
defer watcher.Close()
|
||||
|
||||
go watchForFileChange()
|
||||
|
||||
err = watcher.Add(*hooksFilePath)
|
||||
if err != nil {
|
||||
log.Fatal("error adding hooks file to the watcher", err)
|
||||
}
|
||||
}
|
||||
|
||||
l := negroni.NewLogger()
|
||||
l.Logger = log.New(os.Stdout, "[webhook] ", log.Ldate|log.Ltime)
|
||||
|
||||
negroniRecovery := &negroni.Recovery{
|
||||
Logger: l.Logger,
|
||||
PrintStack: true,
|
||||
StackAll: false,
|
||||
StackSize: 1024 * 8,
|
||||
}
|
||||
|
||||
n := negroni.New(negroniRecovery, l)
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
var hooksURL string
|
||||
|
||||
if *hooksURLPrefix == "" {
|
||||
hooksURL = "/{id}"
|
||||
} else {
|
||||
hooksURL = "/" + *hooksURLPrefix + "/{id}"
|
||||
}
|
||||
|
||||
router.HandleFunc(hooksURL, hookHandler)
|
||||
|
||||
n.UseHandler(router)
|
||||
|
||||
if *secure {
|
||||
log.Printf("starting secure (https) webhook on %s:%d", *ip, *port)
|
||||
log.Fatal(http.ListenAndServeTLS(fmt.Sprintf("%s:%d", *ip, *port), *cert, *key, n))
|
||||
} else {
|
||||
log.Printf("starting insecure (http) webhook on %s:%d", *ip, *port)
|
||||
log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%d", *ip, *port), n))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func hookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
|
||||
matchedHooks := hooks.MatchAll(id)
|
||||
|
||||
if matchedHooks != nil {
|
||||
log.Printf("%s got matched (%d time(s))\n", id, len(matchedHooks))
|
||||
|
||||
body, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Printf("error reading the request body. %+v\n", err)
|
||||
}
|
||||
|
||||
// parse headers
|
||||
headers := valuesToMap(r.Header)
|
||||
|
||||
// parse query variables
|
||||
query := valuesToMap(r.URL.Query())
|
||||
|
||||
// parse body
|
||||
var payload map[string]interface{}
|
||||
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
|
||||
if strings.Contains(contentType, "json") {
|
||||
decoder := json.NewDecoder(strings.NewReader(string(body)))
|
||||
decoder.UseNumber()
|
||||
|
||||
err := decoder.Decode(&payload)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("error parsing JSON payload %+v\n", err)
|
||||
}
|
||||
} else if strings.Contains(contentType, "form") {
|
||||
fd, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
log.Printf("error parsing form payload %+v\n", err)
|
||||
} else {
|
||||
payload = valuesToMap(fd)
|
||||
}
|
||||
}
|
||||
|
||||
// handle hook
|
||||
for _, h := range matchedHooks {
|
||||
h.ParseJSONParameters(&headers, &query, &payload)
|
||||
if h.TriggerRule == nil || h.TriggerRule != nil && h.TriggerRule.Evaluate(&headers, &query, &payload, &body) {
|
||||
log.Printf("%s hook triggered successfully\n", h.ID)
|
||||
|
||||
if h.CaptureCommandOutput {
|
||||
response := handleHook(h, &headers, &query, &payload, &body)
|
||||
fmt.Fprintf(w, response)
|
||||
} else {
|
||||
go handleHook(h, &headers, &query, &payload, &body)
|
||||
fmt.Fprintf(w, h.ResponseMessage)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// if none of the hooks got triggered
|
||||
log.Printf("%s got matched (%d time(s)), but didn't get triggered because the trigger rules were not satisfied\n", matchedHooks[0].ID, len(matchedHooks))
|
||||
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "Hook rules were not satisfied.")
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, "Hook not found.")
|
||||
}
|
||||
}
|
||||
|
||||
func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, body *[]byte) string {
|
||||
cmd := exec.Command(h.ExecuteCommand)
|
||||
cmd.Args = h.ExtractCommandArguments(headers, query, payload)
|
||||
cmd.Dir = h.CommandWorkingDirectory
|
||||
|
||||
log.Printf("executing %s (%s) with arguments %s using %s as cwd\n", h.ExecuteCommand, cmd.Path, cmd.Args, cmd.Dir)
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
log.Printf("command output: %s\n", out)
|
||||
|
||||
var errorResponse string
|
||||
|
||||
if err != nil {
|
||||
log.Printf("error occurred: %+v\n", err)
|
||||
errorResponse = fmt.Sprintf("%+v", err)
|
||||
}
|
||||
|
||||
log.Printf("finished handling %s\n", h.ID)
|
||||
|
||||
var response []byte
|
||||
response, err = json.Marshal(&hook.CommandStatusResponse{ResponseMessage: h.ResponseMessage, Output: string(out), Error: errorResponse})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("error marshalling response: %+v", err)
|
||||
return h.ResponseMessage
|
||||
}
|
||||
|
||||
return string(response)
|
||||
}
|
||||
|
||||
func reloadHooks() {
|
||||
newHooks := hook.Hooks{}
|
||||
|
||||
// parse and swap
|
||||
log.Printf("attempting to reload hooks from %s\n", *hooksFilePath)
|
||||
|
||||
err := newHooks.LoadFromFile(*hooksFilePath)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("couldn't load hooks from file! %+v\n", err)
|
||||
} else {
|
||||
log.Printf("loaded %d hook(s) from file\n", len(hooks))
|
||||
|
||||
for _, hook := range hooks {
|
||||
log.Printf("\t> %s\n", hook.ID)
|
||||
}
|
||||
|
||||
hooks = newHooks
|
||||
}
|
||||
}
|
||||
|
||||
func watchForFileChange() {
|
||||
for {
|
||||
select {
|
||||
case event := <-(*watcher).Events:
|
||||
if event.Op&fsnotify.Write == fsnotify.Write {
|
||||
log.Println("hooks file modified")
|
||||
|
||||
reloadHooks()
|
||||
}
|
||||
case err := <-(*watcher).Errors:
|
||||
log.Println("watcher error:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// valuesToMap converts map[string][]string to a map[string]string object
|
||||
func valuesToMap(values map[string][]string) map[string]interface{} {
|
||||
ret := make(map[string]interface{})
|
||||
|
||||
for key, value := range values {
|
||||
if len(value) > 0 {
|
||||
ret[key] = value[0]
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
Loading…
Add table
Reference in a new issue