Merge pull request #52 from adnanh/development

2.3.6
This commit is contained in:
Adnan Hajdarević 2015-11-13 09:26:08 +01:00
commit a7aa7f2327
12 changed files with 910 additions and 462 deletions

View file

@ -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"]

View file

@ -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"

View file

@ -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

View file

@ -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"`
}

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
signals.go Normal file
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
signals_windows.go Normal file
View 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
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, " "))
}
}

143
test/hooks.json.tmpl Normal file
View 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"
}
}
}
}
]

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,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
View 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.`},
}

View file

@ -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
}