Compare commits

..

14 Commits

Author SHA1 Message Date
Adnan Hajdarević 3978b6687d Merge pull request #125 from adnanh/development
webhook 2.6.3
2017-04-07 11:00:37 +02:00
Adnan Hajdarevic 81b1bd7c7b bump up the version to 2.6.3 2017-04-07 10:59:40 +02:00
Adnan Hajdarevic 45cf312b8d Merge branch 'master' into development 2017-04-07 10:59:15 +02:00
Adnan Hajdarević 2cfc1ce2ff Merge pull request #124 from raphink/sha256
Add sha256 payload check
2017-04-07 10:55:16 +02:00
Raphaël Pinson 6b1021925b Add sha256 payload check 2017-04-06 17:49:31 +02:00
Adnan Hajdarević af22498d1e Update README.md 2017-04-06 17:01:08 +02:00
Adnan Hajdarević 5b567d1631 Update README.md 2017-04-06 11:09:17 +02:00
Adnan Hajdarević 4aa81b727f Merge pull request #116 from handcraftedbits/development
Add IP whitelist match rule
2017-02-21 12:50:48 +01:00
HandcraftedBits 173273b466 Add IP whitelist match rule. 2017-02-20 22:00:39 -05:00
Adnan Hajdarević 8803239e6d Merge pull request #115 from adnanh/development
Support loading hooks from multiple files
2017-02-11 23:01:01 +01:00
Adnan Hajdarevic c8a83349d2 remove hooks after the underlying file is removed 2017-02-11 22:58:49 +01:00
Adnan Hajdarevic 1da40d4634 Merge branch 'master' into development 2017-02-11 12:15:04 +01:00
Adnan Hajdarevic 79453b7a22 Use hooks.json as default file if no hooks file have been specified 2016-12-05 21:28:38 +01:00
Adnan Hajdarevic 8207c6cf12 Started work on multi file hooks loading 2016-12-05 21:22:34 +01:00
5 changed files with 342 additions and 98 deletions
+8
View File
@@ -1,4 +1,12 @@
[![ghit.me](https://ghit.me/badge.svg?repo=adnanh/webhook)](https://ghit.me/repo/adnanh/webhook) [![Join the chat at https://gitter.im/adnanh/webhook](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/adnanh/webhook?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Flattr this](https://button.flattr.com/flattr-badge-large.png)](https://flattr.com/submit/auto?user_id=adnanh&url=https%3A%2F%2Fwww.github.com%2Fadnanh%2Fwebhook) [Donate via PayPal](https://paypal.me/hookdoo) | [Patreon page](https://www.patreon.com/webhook)
# Hookdoo
<a href="https://www.hookdoo.com/?github"><img src="https://my.hookdoo.com/logo/logo-dark-96.png" alt="hookdoo" align="left" /></a>
If you don't have time to waste configuring, hosting, debugging and maintaining your webhook instance, we offer a __SaaS__ solution that has all of the capabilities webhook provides, plus a lot more, and all that packaged in a nice friendly web interface. If you are interested, find out more at [hookdoo website](https://www.hookdoo.com/). If you have any questions, you can contact us at info@hookdoo.com
# What is webhook?
[webhook](https://github.com/adnanh/webhook/) is a lightweight configurable tool written in Go, that allows you to easily create HTTP endpoints (hooks) on your server, which you can use to execute configured commands. You can also pass data from the HTTP request (such as headers, payload or query variables) to your commands. [webhook](https://github.com/adnanh/webhook/) also allows you to specify rules which have to be satisfied in order for the hook to be triggered.
+116 -13
View File
@@ -3,11 +3,13 @@ package hook
import (
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net"
"net/textproto"
"reflect"
"regexp"
@@ -100,6 +102,67 @@ func CheckPayloadSignature(payload []byte, secret string, signature string) (str
return expectedMAC, err
}
// CheckPayloadSignature256 calculates and verifies SHA256 signature of the given payload
func CheckPayloadSignature256(payload []byte, secret string, signature string) (string, error) {
if strings.HasPrefix(signature, "sha256=") {
signature = signature[7:]
}
mac := hmac.New(sha256.New, []byte(secret))
_, err := mac.Write(payload)
if err != nil {
return "", err
}
expectedMAC := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expectedMAC)) {
return expectedMAC, &SignatureError{signature}
}
return expectedMAC, err
}
// CheckIPWhitelist makes sure the provided remote address (of the form IP:port) falls within the provided IP range
// (in CIDR form or a single IP address).
func CheckIPWhitelist(remoteAddr string, ipRange string) (bool, error) {
// Extract IP address from remote address.
ip := remoteAddr
if strings.LastIndex(remoteAddr, ":") != -1 {
ip = remoteAddr[0:strings.LastIndex(remoteAddr, ":")]
}
ip = strings.TrimSpace(ip)
// IPv6 addresses will likely be surrounded by [], so don't forget to remove those.
if strings.HasPrefix(ip, "[") && strings.HasSuffix(ip, "]") {
ip = ip[1 : len(ip)-1]
}
parsedIP := net.ParseIP(strings.TrimSpace(ip))
if parsedIP == nil {
return false, fmt.Errorf("invalid IP address found in remote address '%s'", remoteAddr)
}
// Extract IP range in CIDR form. If a single IP address is provided, turn it into CIDR form.
ipRange = strings.TrimSpace(ipRange)
if strings.Index(ipRange, "/") == -1 {
ipRange = ipRange + "/32"
}
_, cidr, err := net.ParseCIDR(ipRange)
if err != nil {
return false, err
}
return cidr.Contains(parsedIP), nil
}
// ReplaceParameter replaces parameter value with the passed value in the passed map
// (please note you should pass pointer to the map, because we're modifying it)
// based on the passed string
@@ -273,7 +336,7 @@ func (h *ResponseHeaders) String() string {
result[idx] = fmt.Sprintf("%s=%s", responseHeader.Name, responseHeader.Value)
}
return fmt.Sprint(strings.Join(result, ", "))
return strings.Join(result, ", ")
}
// Set method appends new Header object from header=value notation
@@ -288,6 +351,23 @@ func (h *ResponseHeaders) Set(value string) error {
return nil
}
// HooksFiles is a slice of String
type HooksFiles []string
func (h *HooksFiles) String() string {
if len(*h) == 0 {
return "hooks.json"
}
return strings.Join(*h, ", ")
}
// Set method appends new string
func (h *HooksFiles) Set(value string) error {
*h = append(*h, value)
return nil
}
// Hook type is a structure containing details for a single hook
type Hook struct {
ID string `json:"id,omitempty"`
@@ -427,6 +507,19 @@ func (h *Hooks) LoadFromFile(path string) error {
return e
}
// Append appends hooks unless the new hooks contain a hook with an ID that already exists
func (h *Hooks) Append(other *Hooks) error {
for _, hook := range *other {
if h.Match(hook.ID) != nil {
return fmt.Errorf("hook with ID %s is already defined", hook.ID)
}
*h = append(*h, hook)
}
return nil
}
// Match iterates through Hooks and returns first one that matches the given ID,
// if no hook matches the given ID, nil is returned
func (h *Hooks) Match(id string) *Hook {
@@ -449,16 +542,16 @@ 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, error) {
func (r Rules) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte, remoteAddr string) (bool, error) {
switch {
case r.And != nil:
return r.And.Evaluate(headers, query, payload, body)
return r.And.Evaluate(headers, query, payload, body, remoteAddr)
case r.Or != nil:
return r.Or.Evaluate(headers, query, payload, body)
return r.Or.Evaluate(headers, query, payload, body, remoteAddr)
case r.Not != nil:
return r.Not.Evaluate(headers, query, payload, body)
return r.Not.Evaluate(headers, query, payload, body, remoteAddr)
case r.Match != nil:
return r.Match.Evaluate(headers, query, payload, body)
return r.Match.Evaluate(headers, query, payload, body, remoteAddr)
}
return false, nil
@@ -468,11 +561,11 @@ func (r Rules) Evaluate(headers, query, payload *map[string]interface{}, body *[
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, error) {
func (r AndRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte, remoteAddr string) (bool, error) {
res := true
for _, v := range r {
rv, err := v.Evaluate(headers, query, payload, body)
rv, err := v.Evaluate(headers, query, payload, body, remoteAddr)
if err != nil {
return false, err
}
@@ -490,11 +583,11 @@ func (r AndRule) Evaluate(headers, query, payload *map[string]interface{}, body
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, error) {
func (r OrRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte, remoteAddr string) (bool, error) {
res := false
for _, v := range r {
rv, err := v.Evaluate(headers, query, payload, body)
rv, err := v.Evaluate(headers, query, payload, body, remoteAddr)
if err != nil {
return false, err
}
@@ -512,8 +605,8 @@ func (r OrRule) Evaluate(headers, query, payload *map[string]interface{}, body *
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, error) {
rv, err := Rules(r).Evaluate(headers, query, payload, body)
func (r NotRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte, remoteAddr string) (bool, error) {
rv, err := Rules(r).Evaluate(headers, query, payload, body, remoteAddr)
return !rv, err
}
@@ -524,6 +617,7 @@ type MatchRule struct {
Secret string `json:"secret,omitempty"`
Value string `json:"value,omitempty"`
Parameter Argument `json:"parameter,omitempty"`
IPRange string `json:"ip-range,omitempty"`
}
// Constants for the MatchRule type
@@ -531,10 +625,16 @@ const (
MatchValue string = "value"
MatchRegex string = "regex"
MatchHashSHA1 string = "payload-hash-sha1"
MatchHashSHA256 string = "payload-hash-sha256"
IPWhitelist string = "ip-whitelist"
)
// Evaluate MatchRule will return based on the type
func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) {
func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte, remoteAddr string) (bool, error) {
if r.Type == IPWhitelist {
return CheckIPWhitelist(remoteAddr, r.IPRange)
}
if arg, ok := r.Parameter.Get(headers, query, payload); ok {
switch r.Type {
case MatchValue:
@@ -544,6 +644,9 @@ func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, bod
case MatchHashSHA1:
_, err := CheckPayloadSignature(*body, r.Secret, arg)
return err == nil, err
case MatchHashSHA256:
_, err := CheckPayloadSignature256(*body, r.Secret, arg)
return err == nil, err
}
}
return false, nil
+73 -31
View File
@@ -33,6 +33,32 @@ func TestCheckPayloadSignature(t *testing.T) {
}
}
var checkPayloadSignature256Tests = []struct {
payload []byte
secret string
signature string
mac string
ok bool
}{
{[]byte(`{"a": "z"}`), "secret", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", true},
{[]byte(`{"a": "z"}`), "secret", "sha256=f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", true},
// failures
{[]byte(`{"a": "z"}`), "secret", "XXX7af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", false},
}
func TestCheckPayloadSignature256(t *testing.T) {
for _, tt := range checkPayloadSignature256Tests {
mac, err := CheckPayloadSignature256(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))
}
if err != nil && strings.Contains(err.Error(), tt.mac) {
t.Errorf("error message should not disclose expected mac: %s", err)
}
}
}
var extractParameterTests = []struct {
s string
params interface{}
@@ -241,29 +267,45 @@ func TestHooksMatch(t *testing.T) {
}
var matchRuleTests = []struct {
typ, regex, secret, value string
typ, regex, secret, value, ipRange string
param Argument
headers, query, payload *map[string]interface{}
body []byte
remoteAddr string
ok bool
err bool
}{
{"value", "", "", "z", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, true, false},
{"regex", "^z", "", "z", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, true, false},
{"payload-hash-sha1", "", "secret", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "b17e04cbb22afa8ffbff8796fc1894ed27badd9e"}, nil, nil, []byte(`{"a": "z"}`), true, false},
{"payload-hash-sha256", "", "secret", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89"}, nil, nil, []byte(`{"a": "z"}`), true, false},
// failures
{"value", "", "", "X", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, false, false},
{"regex", "^X", "", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, false, false},
{"value", "", "2", "X", Argument{"header", "a", ""}, &map[string]interface{}{"Y": "z"}, nil, nil, []byte{}, false, false}, // reference invalid header
{"value", "", "", "X", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, "", false, false},
{"regex", "^X", "", "", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, "", false, false},
{"value", "", "2", "X", "", Argument{"header", "a", ""}, &map[string]interface{}{"Y": "z"}, nil, nil, []byte{}, "", false, false}, // reference invalid header
// errors
{"regex", "*", "", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, false, true}, // invalid regex
{"payload-hash-sha1", "", "secret", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": ""}, nil, nil, []byte{}, false, true}, // invalid hmac
{"regex", "*", "", "", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, "", false, true}, // invalid regex
{"payload-hash-sha1", "", "secret", "", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": ""}, nil, nil, []byte{}, "", false, true}, // invalid hmac
{"payload-hash-sha256", "", "secret", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": ""}, nil, nil, []byte{}, false, true}, // invalid hmac
// IP whitelisting, valid cases
{"ip-whitelist", "", "", "", "192.168.0.1/24", Argument{}, nil, nil, nil, []byte{}, "192.168.0.2:9000", true, false}, // valid IPv4, with range
{"ip-whitelist", "", "", "", "192.168.0.1/24", Argument{}, nil, nil, nil, []byte{}, "192.168.0.2:9000", true, false}, // valid IPv4, with range
{"ip-whitelist", "", "", "", "192.168.0.1", Argument{}, nil, nil, nil, []byte{}, "192.168.0.1:9000", true, false}, // valid IPv4, no range
{"ip-whitelist", "", "", "", "::1/24", Argument{}, nil, nil, nil, []byte{}, "[::1]:9000", true, false}, // valid IPv6, with range
{"ip-whitelist", "", "", "", "::1", Argument{}, nil, nil, nil, []byte{}, "[::1]:9000", true, false}, // valid IPv6, no range
// IP whitelisting, invalid cases
{"ip-whitelist", "", "", "", "192.168.0.1/a", Argument{}, nil, nil, nil, []byte{}, "192.168.0.2:9000", false, true}, // invalid IPv4, with range
{"ip-whitelist", "", "", "", "192.168.0.a", Argument{}, nil, nil, nil, []byte{}, "192.168.0.2:9000", false, true}, // invalid IPv4, no range
{"ip-whitelist", "", "", "", "192.168.0.1/24", Argument{}, nil, nil, nil, []byte{}, "192.168.0.a:9000", false, true}, // invalid IPv4 address
{"ip-whitelist", "", "", "", "::1/a", Argument{}, nil, nil, nil, []byte{}, "[::1]:9000", false, true}, // invalid IPv6, with range
{"ip-whitelist", "", "", "", "::z", Argument{}, nil, nil, nil, []byte{}, "[::1]:9000", false, true}, // invalid IPv6, no range
{"ip-whitelist", "", "", "", "::1/24", Argument{}, nil, nil, nil, []byte{}, "[::z]:9000", false, true}, // invalid IPv6 address
}
func TestMatchRule(t *testing.T) {
for i, tt := range matchRuleTests {
r := MatchRule{tt.typ, tt.regex, tt.secret, tt.value, tt.param}
ok, err := r.Evaluate(tt.headers, tt.query, tt.payload, &tt.body)
r := MatchRule{tt.typ, tt.regex, tt.secret, tt.value, tt.param, tt.ipRange}
ok, err := r.Evaluate(tt.headers, tt.query, tt.payload, &tt.body, tt.remoteAddr)
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))
}
@@ -281,8 +323,8 @@ var andRuleTests = []struct {
{
"(a=z, b=y): a=z && b=y",
AndRule{
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}},
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}, ""}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}, ""}},
},
&map[string]interface{}{"A": "z", "B": "y"}, nil, nil, []byte{},
true, false,
@@ -290,8 +332,8 @@ var andRuleTests = []struct {
{
"(a=z, b=Y): a=z && b=y",
AndRule{
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}},
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}, ""}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}, ""}},
},
&map[string]interface{}{"A": "z", "B": "Y"}, nil, nil, []byte{},
false, false,
@@ -300,22 +342,22 @@ var andRuleTests = []struct {
{
"(a=z, b=y, c=x, d=w=, e=X, f=X): a=z && (b=y && c=x) && (d=w || e=v) && !f=u",
AndRule{
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}, ""}},
{
And: &AndRule{
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}},
{Match: &MatchRule{"value", "", "", "x", Argument{"header", "c", ""}}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}, ""}},
{Match: &MatchRule{"value", "", "", "x", Argument{"header", "c", ""}, ""}},
},
},
{
Or: &OrRule{
{Match: &MatchRule{"value", "", "", "w", Argument{"header", "d", ""}}},
{Match: &MatchRule{"value", "", "", "v", Argument{"header", "e", ""}}},
{Match: &MatchRule{"value", "", "", "w", Argument{"header", "d", ""}, ""}},
{Match: &MatchRule{"value", "", "", "v", Argument{"header", "e", ""}, ""}},
},
},
{
Not: &NotRule{
Match: &MatchRule{"value", "", "", "u", Argument{"header", "f", ""}},
Match: &MatchRule{"value", "", "", "u", Argument{"header", "f", ""}, ""},
},
},
},
@@ -326,7 +368,7 @@ var andRuleTests = []struct {
// failures
{
"invalid rule",
AndRule{{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", ""}}}},
AndRule{{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", ""}, ""}}},
&map[string]interface{}{"Y": "z"}, nil, nil, nil,
false, false,
},
@@ -334,7 +376,7 @@ var andRuleTests = []struct {
func TestAndRule(t *testing.T) {
for _, tt := range andRuleTests {
ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body)
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)
}
@@ -352,8 +394,8 @@ var orRuleTests = []struct {
{
"(a=z, b=X): a=z || b=y",
OrRule{
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}},
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}, ""}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}, ""}},
},
&map[string]interface{}{"A": "z", "B": "X"}, nil, nil, []byte{},
true, false,
@@ -361,8 +403,8 @@ var orRuleTests = []struct {
{
"(a=X, b=y): a=z || b=y",
OrRule{
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}},
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}, ""}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}, ""}},
},
&map[string]interface{}{"A": "X", "B": "y"}, nil, nil, []byte{},
true, false,
@@ -370,8 +412,8 @@ var orRuleTests = []struct {
{
"(a=Z, b=Y): a=z || b=y",
OrRule{
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}},
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}, ""}},
{Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}, ""}},
},
&map[string]interface{}{"A": "Z", "B": "Y"}, nil, nil, []byte{},
false, false,
@@ -380,7 +422,7 @@ var orRuleTests = []struct {
{
"invalid rule",
OrRule{
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}},
{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}, ""}},
},
&map[string]interface{}{"Y": "Z"}, nil, nil, []byte{},
false, false,
@@ -389,7 +431,7 @@ var orRuleTests = []struct {
func TestOrRule(t *testing.T) {
for _, tt := range orRuleTests {
ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body)
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)
}
@@ -404,13 +446,13 @@ var notRuleTests = []struct {
ok bool
err bool
}{
{"(a=z): !a=X", NotRule{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", ""}}}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, true, false},
{"(a=z): !a=z", NotRule{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, false, false},
{"(a=z): !a=X", NotRule{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", ""}, ""}}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, true, false},
{"(a=z): !a=z", NotRule{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}, ""}}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, false, false},
}
func TestNotRule(t *testing.T) {
for _, tt := range notRuleTests {
ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body)
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)
}
+1 -1
View File
@@ -26,7 +26,7 @@ func watchForSignals() {
if sig == syscall.SIGUSR1 {
log.Println("caught USR1 signal")
reloadHooks()
reloadAllHooks()
} else {
log.Printf("caught unhandled signal %+v\n", sig)
}
+131 -40
View File
@@ -21,7 +21,7 @@ import (
)
const (
version = "2.6.1"
version = "2.6.3"
)
var (
@@ -30,7 +30,6 @@ var (
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")
@@ -38,16 +37,35 @@ var (
justDisplayVersion = flag.Bool("version", false, "display webhook version and quit")
responseHeaders hook.ResponseHeaders
hooksFiles hook.HooksFiles
loadedHooksFromFiles = make(map[string]hook.Hooks)
watcher *fsnotify.Watcher
signals chan os.Signal
hooks hook.Hooks
)
func main() {
hooks = hook.Hooks{}
func matchLoadedHook(id string) *hook.Hook {
for _, hooks := range loadedHooksFromFiles {
if hook := hooks.Match(id); hook != nil {
return hook
}
}
return nil
}
func lenLoadedHooks() int {
sum := 0
for _, hooks := range loadedHooksFromFiles {
sum += len(hooks)
}
return sum
}
func main() {
flag.Var(&hooksFiles, "hooks", "path to the json file containing defined hooks the webhook should serve, use multiple times to load from different files")
flag.Var(&responseHeaders, "header", "response header to return, specified in format name=value, use multiple times to set multiple headers")
flag.Parse()
@@ -57,6 +75,10 @@ func main() {
os.Exit(0)
}
if len(hooksFiles) == 0 {
hooksFiles = append(hooksFiles, "hooks.json")
}
log.SetPrefix("[webhook] ")
log.SetFlags(log.Ldate | log.Ltime)
@@ -70,52 +92,65 @@ func main() {
setupSignals()
// load and parse hooks
log.Printf("attempting to load hooks from %s\n", *hooksFilePath)
for _, hooksFilePath := range hooksFiles {
log.Printf("attempting to load hooks from %s\n", hooksFilePath)
err := hooks.LoadFromFile(*hooksFilePath)
newHooks := hook.Hooks{}
err := newHooks.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 {
seenHooksIds := make(map[string]bool)
log.Printf("found %d hook(s) in file\n", len(newHooks))
log.Printf("found %d hook(s) in file\n", len(hooks))
for _, hook := range hooks {
if seenHooksIds[hook.ID] == true {
for _, hook := range newHooks {
if matchLoadedHook(hook.ID) != nil {
log.Fatalf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!\n", hook.ID)
}
seenHooksIds[hook.ID] = true
log.Printf("\tloaded: %s\n", hook.ID)
}
loadedHooksFromFiles[hooksFilePath] = newHooks
}
}
newHooksFiles := hooksFiles[:0]
for _, filePath := range hooksFiles {
if _, ok := loadedHooksFromFiles[filePath]; ok == true {
newHooksFiles = append(newHooksFiles, filePath)
}
}
hooksFiles = newHooksFiles
if !*verbose && !*noPanic && lenLoadedHooks() == 0 {
log.SetOutput(os.Stdout)
log.Fatalln("couldn't load any hooks from file!\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to start without the hooks, either use -verbose flag, or -nopanic")
}
if *hotReload {
// 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)
log.Fatal("error creating file watcher instance\n", err)
}
defer watcher.Close()
go watchForFileChange()
for _, hooksFilePath := range hooksFiles {
// set up file watcher
log.Printf("setting up file watcher for %s\n", hooksFilePath)
err = watcher.Add(*hooksFilePath)
err = watcher.Add(hooksFilePath)
if err != nil {
log.Fatal("error adding hooks file to the watcher", err)
log.Fatal("error adding hooks file to the watcher\n", err)
}
}
go watchForFileChange()
}
l := negroni.NewLogger()
l.ALogger = log.New(os.Stderr, "[webhook] ", log.Ldate|log.Ltime)
@@ -159,7 +194,7 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
if matchedHook := hooks.Match(id); matchedHook != nil {
if matchedHook := matchLoadedHook(id); matchedHook != nil {
log.Printf("%s got matched\n", id)
body, err := ioutil.ReadAll(r.Body)
@@ -208,7 +243,7 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
if matchedHook.TriggerRule == nil {
ok = true
} else {
ok, err = matchedHook.TriggerRule.Evaluate(&headers, &query, &payload, &body)
ok, err = matchedHook.TriggerRule.Evaluate(&headers, &query, &payload, &body, r.RemoteAddr)
if err != nil {
msg := fmt.Sprintf("error evaluating hook: %s", err)
log.Printf(msg)
@@ -302,32 +337,74 @@ func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, b
return string(out), err
}
func reloadHooks() {
newHooks := hook.Hooks{}
func reloadHooks(hooksFilePath string) {
hooksInFile := hook.Hooks{}
// parse and swap
log.Printf("attempting to reload hooks from %s\n", *hooksFilePath)
log.Printf("attempting to reload hooks from %s\n", hooksFilePath)
err := newHooks.LoadFromFile(*hooksFilePath)
err := hooksInFile.LoadFromFile(hooksFilePath)
if err != nil {
log.Printf("couldn't load hooks from file! %+v\n", err)
} else {
seenHooksIds := make(map[string]bool)
log.Printf("found %d hook(s) in file\n", len(newHooks))
log.Printf("found %d hook(s) in file\n", len(hooksInFile))
for _, hook := range newHooks {
if seenHooksIds[hook.ID] == true {
for _, hook := range hooksInFile {
wasHookIDAlreadyLoaded := false
for _, loadedHook := range loadedHooksFromFiles[hooksFilePath] {
if loadedHook.ID == hook.ID {
wasHookIDAlreadyLoaded = true
break
}
}
if (matchLoadedHook(hook.ID) != nil && !wasHookIDAlreadyLoaded) || seenHooksIds[hook.ID] == true {
log.Printf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!", hook.ID)
log.Println("reverting hooks back to the previous configuration")
return
}
seenHooksIds[hook.ID] = true
log.Printf("\tloaded: %s\n", hook.ID)
}
hooks = newHooks
loadedHooksFromFiles[hooksFilePath] = hooksInFile
}
}
func reloadAllHooks() {
for _, hooksFilePath := range hooksFiles {
reloadHooks(hooksFilePath)
}
}
func removeHooks(hooksFilePath string) {
for _, hook := range loadedHooksFromFiles[hooksFilePath] {
log.Printf("\tremoving: %s\n", hook.ID)
}
newHooksFiles := hooksFiles[:0]
for _, filePath := range hooksFiles {
if filePath != hooksFilePath {
newHooksFiles = append(newHooksFiles, filePath)
}
}
hooksFiles = newHooksFiles
removedHooksCount := len(loadedHooksFromFiles[hooksFilePath])
delete(loadedHooksFromFiles, hooksFilePath)
log.Printf("removed %d hook(s) that were loaded from file %s\n", removedHooksCount, hooksFilePath)
if !*verbose && !*noPanic && lenLoadedHooks() == 0 {
log.SetOutput(os.Stdout)
log.Fatalln("couldn't load any hooks from file!\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to run without the hooks, either use -verbose flag, or -nopanic")
}
}
@@ -336,9 +413,23 @@ func watchForFileChange() {
select {
case event := <-(*watcher).Events:
if event.Op&fsnotify.Write == fsnotify.Write {
log.Println("hooks file modified")
reloadHooks()
log.Printf("hooks file %s modified\n", event.Name)
reloadHooks(event.Name)
} else if event.Op&fsnotify.Remove == fsnotify.Remove {
log.Printf("hooks file %s removed, no longer watching this file for changes, removing hooks that were loaded from it\n", event.Name)
(*watcher).Remove(event.Name)
removeHooks(event.Name)
} else if event.Op&fsnotify.Rename == fsnotify.Rename {
if _, err := os.Stat(event.Name); os.IsNotExist(err) {
// file was removed
log.Printf("hooks file %s removed, no longer watching this file for changes, and removing hooks that were loaded from it\n", event.Name)
(*watcher).Remove(event.Name)
removeHooks(event.Name)
} else {
// file was overwritten
log.Printf("hooks file %s overwritten\n", event.Name)
reloadHooks(event.Name)
}
}
case err := <-(*watcher).Errors:
log.Println("watcher error:", err)