mirror of
https://github.com/adnanh/webhook.git
synced 2026-07-26 17:19:17 +08:00
Compare commits
157 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3978b6687d | |||
| 81b1bd7c7b | |||
| 45cf312b8d | |||
| 2cfc1ce2ff | |||
| 6b1021925b | |||
| af22498d1e | |||
| 5b567d1631 | |||
| 4aa81b727f | |||
| 173273b466 | |||
| 8803239e6d | |||
| c8a83349d2 | |||
| 1da40d4634 | |||
| c51971fd37 | |||
| 93e5fe7712 | |||
| 058f820cbd | |||
| ddb1f2441a | |||
| 8226d5e50f | |||
| e149c99724 | |||
| 6ea2d68b44 | |||
| 79453b7a22 | |||
| 8207c6cf12 | |||
| 5789362186 | |||
| 07f166616c | |||
| b66216675a | |||
| ecbcf11153 | |||
| 7d525cf317 | |||
| e83d7029ff | |||
| 36c2c692d6 | |||
| 86cef3e421 | |||
| 75cf8952be | |||
| c53596df59 | |||
| cc0d9b2cba | |||
| c6530b17e7 | |||
| 1943c5311f | |||
| 3b59539a33 | |||
| 923b0c6daa | |||
| 8530255ae6 | |||
| 032c74451f | |||
| 1c50853d8d | |||
| b5ed4cbea7 | |||
| b6d176705e | |||
| 421fc2cbcd | |||
| 10d65dd2fd | |||
| 54a9dbe1d6 | |||
| 30baec91df | |||
| 3bcf6d5e2b | |||
| 67343e281d | |||
| 18b0573bc4 | |||
| ec42679305 | |||
| e85e0592dd | |||
| 4d20af8027 | |||
| d4e772c815 | |||
| 04a2b2a680 | |||
| 4914a4131f | |||
| 37698e63b6 | |||
| 80aa9800bf | |||
| f620cb056b | |||
| e55e7efe14 | |||
| 9fa02f7341 | |||
| f59f0a5c84 | |||
| 5594a62f8f | |||
| 642516d46e | |||
| 9cef8ed882 | |||
| 8de1a51926 | |||
| ed9d557a13 | |||
| d2e315d9c6 | |||
| a7aa7f2327 | |||
| 82bba7ba07 | |||
| 8a9ff6d06c | |||
| 3080fd115b | |||
| b314eda1f9 | |||
| 8544653787 | |||
| 8d1498e285 | |||
| 2ed15e35bc | |||
| aff3a155d7 | |||
| 5f041cb9ae | |||
| b7081f3934 | |||
| 2026328c56 | |||
| ea3dbf3438 | |||
| 6774079a57 | |||
| 334fbc814c | |||
| 802f3f572c | |||
| 6da00561bf | |||
| 2e40847eb9 | |||
| 5f7dd7211b | |||
| db25fcd06d | |||
| d1b4ef767a | |||
| 64b3256408 | |||
| bfe4c148a5 | |||
| 033764601d | |||
| 8173f4a346 | |||
| ffd05a2101 | |||
| fea31474bd | |||
| 1c319a7a08 | |||
| 2947e5e0e8 | |||
| 409b441c31 | |||
| 93505b4132 | |||
| c350784507 | |||
| 7b8cc04992 | |||
| d4810bebdb | |||
| 272546bb2b | |||
| 2afc6e6a54 | |||
| a5c92b88a6 | |||
| d12bbf4036 | |||
| d91b34a316 | |||
| 005e723b23 | |||
| 9977fa8c61 | |||
| cbe2440cda | |||
| 9c545a745f | |||
| 263c75b1b5 | |||
| 83cbffd37c | |||
| b310b79fb8 | |||
| f1ebc440a4 | |||
| 10732bd57b | |||
| 4350685330 | |||
| 6053f48b23 | |||
| 6cd8258651 | |||
| fb71ea0fae | |||
| aeacb6dac7 | |||
| 1039151a16 | |||
| db928228c8 | |||
| 6896a34aab | |||
| 5f853d8aba | |||
| 12c48f87cb | |||
| acf38c3210 | |||
| d3f368cb8f | |||
| 943bc258f7 | |||
| 231426da57 | |||
| baec1cadc5 | |||
| 688483d6d1 | |||
| bddb523b67 | |||
| b8807ed434 | |||
| 8527a9b23e | |||
| becd8935be | |||
| 230d16dd93 | |||
| 9a7dedbc09 | |||
| 84ce6f262a | |||
| 7dd55f5232 | |||
| d8a21582a3 | |||
| 8a627f7e67 | |||
| 7635cfde33 | |||
| 2a2a20dcb8 | |||
| 10755eb9d9 | |||
| e9aaeb579e | |||
| 956589fab3 | |||
| f83af97138 | |||
| 5a96a5721a | |||
| 37b310feaa | |||
| ad76b51e6a | |||
| 4304aaa2d5 | |||
| a2fac2373b | |||
| c9adceb5d8 | |||
| 8488d3c432 | |||
| d3f5da5489 | |||
| 59b4954845 | |||
| 36aea82855 | |||
| 652109d46e |
@@ -0,0 +1,4 @@
|
||||
.idea
|
||||
.cover
|
||||
coverage
|
||||
webhook
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
OS = darwin freebsd linux openbsd windows
|
||||
ARCHS = 386 arm amd64 arm64
|
||||
|
||||
all: build release
|
||||
|
||||
build: deps
|
||||
go build
|
||||
|
||||
release: clean deps
|
||||
@for arch in $(ARCHS);\
|
||||
do \
|
||||
for os in $(OS);\
|
||||
do \
|
||||
echo "Building $$os-$$arch"; \
|
||||
mkdir -p build/webhook-$$os-$$arch/; \
|
||||
GOOS=$$os GOARCH=$$arch go build -o build/webhook-$$os-$$arch/webhook; \
|
||||
tar cz -C build -f build/webhook-$$os-$$arch.tar.gz webhook-$$os-$$arch; \
|
||||
done \
|
||||
done
|
||||
|
||||
test: deps
|
||||
go test ./...
|
||||
|
||||
deps:
|
||||
go get -d -v -t ./...
|
||||
|
||||
clean:
|
||||
rm -rf build
|
||||
rm -f webhook
|
||||
@@ -1,3 +1,12 @@
|
||||
[](https://ghit.me/repo/adnanh/webhook) [](https://gitter.im/adnanh/webhook?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [](https://flattr.com/submit/auto?user_id=adnanh&url=https%3A%2F%2Fwww.github.com%2Fadnanh%2Fwebhook) [Donate via PayPal](https://paypal.me/hookdoo) | [Patreon page](https://www.patreon.com/webhook)
|
||||
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -9,19 +18,27 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
# Getting started
|
||||
## Installation
|
||||
### Building from source
|
||||
To get started, first make sure you've properly set up your [Golang](http://golang.org/doc/install) environment and then run the
|
||||
```bash
|
||||
$ go get github.com/adnanh/webhook
|
||||
```
|
||||
to get the latest version of the [webhook](https://github.com/adnanh/webhook/).
|
||||
|
||||
### Using package manager
|
||||
#### Debian "sid"
|
||||
If you are using unstable version of Debian linux ("sid"), you can install webhook using `apt-get install webhook` which will install community packaged version (thanks [@freeekanayaka](https://github.com/freeekanayaka)) from https://packages.debian.org/sid/webhook
|
||||
|
||||
## Configuration
|
||||
Next step is to define some hooks you want [webhook](https://github.com/adnanh/webhook/) to serve. Begin by creating an empty file named `hooks.json`. This file will contain an array of hooks the [webhook](https://github.com/adnanh/webhook/) will serve. Check [Hook definition page](https://github.com/adnanh/webhook/wiki/Hook-Definition) to see the detailed description of what properties a hook can contain, and how to use them.
|
||||
|
||||
Let's define a simple hook named `redeploy-webhook` that will run a redeploy script located in `/var/scripts/redeploy.sh`.
|
||||
@@ -47,18 +64,32 @@ It will start up on default port 9000 and will provide you with one HTTP endpoin
|
||||
http://yourserver:9000/hooks/redeploy-webhook
|
||||
```
|
||||
|
||||
Check [webhook parameters page](https://github.com/adnanh/webhook/wiki/Webhook-Parameters) to see how to override the ip, port and other settings when starting the [webhook](https://github.com/adnanh/webhook/).
|
||||
Check [webhook parameters page](https://github.com/adnanh/webhook/wiki/Webhook-Parameters) to see how to override the ip, port and other settings such as hook hotreload, verbose output, etc, when starting the [webhook](https://github.com/adnanh/webhook/).
|
||||
|
||||
By performing a simple HTTP GET or POST request to that endpoint, your specified redeploy script would be executed. Neat!
|
||||
|
||||
However, hook defined like that could pose a security threat to your system, because anyone who knows your endpoint, can send a request and execute your command. To prevent that, you can use the `"trigger-rule"` property for your hook, to specify the exact circumstances under which the hook would be triggered. For example, you can use them to add a secret that you must supply as a parameter in order to successfully trigger the hook. Please check out the [Hook rules page](https://github.com/adnanh/webhook/wiki/Hook-Rules) for detailed list of available rules and their usage.
|
||||
|
||||
# Using HTTPS
|
||||
[webhook](https://github.com/adnanh/webhook/) by default serves hooks using http. If you want [webhook](https://github.com/adnanh/webhook/) to serve secure content using https, you can use the `-secure` flag while starting [webhook](https://github.com/adnanh/webhook/). Files containing a certificate and matching private key for the server must be provided using the `-cert /path/to/cert.pem` and `-key /path/to/key.pem` flags. If the certificate is signed by a certificate authority, the cert file should be the concatenation of the server's certificate followed by the CA's certificate.
|
||||
|
||||
# CORS Headers
|
||||
If you want to set CORS headers, you can use the `-header name=value` flag while starting [webhook](https://github.com/adnanh/webhook/) to set the appropriate CORS headers that will be returned with each response.
|
||||
|
||||
# Interested in running webhook inside of a Docker container?
|
||||
You can use [almir/webhook](https://hub.docker.com/r/almir/webhook/) docker image, or create your own (please read [this discussion](https://github.com/adnanh/webhook/issues/63)).
|
||||
|
||||
# Examples
|
||||
Check out [Hook examples page](https://github.com/adnanh/webhook/wiki/Hook-Examples) for more complex examples of hooks.
|
||||
|
||||
# Contributing
|
||||
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)
|
||||
@@ -82,3 +113,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
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CheckPayloadSignature calculates and verifies SHA1 signature of the given payload
|
||||
func CheckPayloadSignature(payload []byte, secret string, signature string) (string, bool) {
|
||||
if strings.HasPrefix(signature, "sha1=") {
|
||||
signature = signature[5:]
|
||||
}
|
||||
|
||||
mac := hmac.New(sha1.New, []byte(secret))
|
||||
mac.Write(payload)
|
||||
expectedMAC := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return expectedMAC, hmac.Equal([]byte(signature), []byte(expectedMAC))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// ExtractParameter extracts value from interface{} based on the passed string
|
||||
func ExtractParameter(s string, params interface{}) (string, bool) {
|
||||
if params == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var p []string
|
||||
|
||||
if paramsValue := reflect.ValueOf(params); paramsValue.Kind() == reflect.Slice {
|
||||
if paramsValueSliceLength := paramsValue.Len(); paramsValueSliceLength > 0 {
|
||||
|
||||
if p = strings.SplitN(s, ".", 3); len(p) > 3 {
|
||||
index, err := strconv.ParseInt(p[1], 10, 64)
|
||||
|
||||
if err != nil {
|
||||
return "", false
|
||||
} else if paramsValueSliceLength <= int(index) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return ExtractParameter(p[2], params.([]map[string]interface{})[index])
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
if p = strings.SplitN(s, ".", 2); len(p) > 1 {
|
||||
if pValue, ok := params.(map[string]interface{})[p[0]]; ok {
|
||||
return ExtractParameter(p[1], pValue)
|
||||
}
|
||||
} else {
|
||||
if pValue, ok := params.(map[string]interface{})[p[0]]; ok {
|
||||
return fmt.Sprintf("%v", pValue), true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
+501
-62
@@ -1,62 +1,446 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"reflect"
|
||||
"regexp"
|
||||
|
||||
"github.com/adnanh/webhook/helpers"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Constants used to specify the parameter source
|
||||
const (
|
||||
SourceHeader string = "header"
|
||||
SourceQuery string = "url"
|
||||
SourcePayload string = "payload"
|
||||
SourceHeader string = "header"
|
||||
SourceQuery string = "url"
|
||||
SourceQueryAlias string = "query"
|
||||
SourcePayload string = "payload"
|
||||
SourceString string = "string"
|
||||
SourceEntirePayload string = "entire-payload"
|
||||
SourceEntireQuery string = "entire-query"
|
||||
SourceEntireHeaders string = "entire-headers"
|
||||
)
|
||||
|
||||
const (
|
||||
// EnvNamespace is the prefix used for passing arguments into the command
|
||||
// environment.
|
||||
EnvNamespace string = "HOOK_"
|
||||
)
|
||||
|
||||
// SignatureError describes an invalid payload signature passed to Hook.
|
||||
type SignatureError struct {
|
||||
Signature string
|
||||
}
|
||||
|
||||
func (e *SignatureError) Error() string {
|
||||
if e == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return fmt.Sprintf("invalid payload signature %s", e.Signature)
|
||||
}
|
||||
|
||||
// ArgumentError describes an invalid argument passed to Hook.
|
||||
type ArgumentError struct {
|
||||
Argument Argument
|
||||
}
|
||||
|
||||
func (e *ArgumentError) Error() string {
|
||||
if e == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return fmt.Sprintf("couldn't retrieve argument for %+v", e.Argument)
|
||||
}
|
||||
|
||||
// SourceError describes an invalid source passed to Hook.
|
||||
type SourceError struct {
|
||||
Argument Argument
|
||||
}
|
||||
|
||||
func (e *SourceError) Error() string {
|
||||
if e == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return fmt.Sprintf("invalid source for argument %+v", e.Argument)
|
||||
}
|
||||
|
||||
// ParseError describes an error parsing user input.
|
||||
type ParseError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ParseError) Error() string {
|
||||
if e == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
// CheckPayloadSignature calculates and verifies SHA1 signature of the given payload
|
||||
func CheckPayloadSignature(payload []byte, secret string, signature string) (string, error) {
|
||||
if strings.HasPrefix(signature, "sha1=") {
|
||||
signature = signature[5:]
|
||||
}
|
||||
|
||||
mac := hmac.New(sha1.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
|
||||
}
|
||||
|
||||
// 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
|
||||
func ReplaceParameter(s string, params interface{}, value interface{}) bool {
|
||||
if params == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if paramsValue := reflect.ValueOf(params); paramsValue.Kind() == reflect.Slice {
|
||||
if paramsValueSliceLength := paramsValue.Len(); paramsValueSliceLength > 0 {
|
||||
|
||||
if p := strings.SplitN(s, ".", 2); len(p) > 1 {
|
||||
index, err := strconv.ParseUint(p[0], 10, 64)
|
||||
|
||||
if err != nil || paramsValueSliceLength <= int(index) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ReplaceParameter(p[1], params.([]interface{})[index], value)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if p := strings.SplitN(s, ".", 2); len(p) > 1 {
|
||||
if pValue, ok := params.(map[string]interface{})[p[0]]; ok {
|
||||
return ReplaceParameter(p[1], pValue, value)
|
||||
}
|
||||
} else {
|
||||
if _, ok := (*params.(*map[string]interface{}))[p[0]]; ok {
|
||||
(*params.(*map[string]interface{}))[p[0]] = value
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetParameter extracts interface{} value based on the passed string
|
||||
func GetParameter(s string, params interface{}) (interface{}, bool) {
|
||||
if params == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if paramsValue := reflect.ValueOf(params); paramsValue.Kind() == reflect.Slice {
|
||||
if paramsValueSliceLength := paramsValue.Len(); paramsValueSliceLength > 0 {
|
||||
|
||||
if p := strings.SplitN(s, ".", 2); len(p) > 1 {
|
||||
index, err := strconv.ParseUint(p[0], 10, 64)
|
||||
|
||||
if err != nil || paramsValueSliceLength <= int(index) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return GetParameter(p[1], params.([]interface{})[index])
|
||||
}
|
||||
|
||||
index, err := strconv.ParseUint(s, 10, 64)
|
||||
|
||||
if err != nil || paramsValueSliceLength <= int(index) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return params.([]interface{})[index], true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if p := strings.SplitN(s, ".", 2); len(p) > 1 {
|
||||
if paramsValue := reflect.ValueOf(params); paramsValue.Kind() == reflect.Map {
|
||||
if pValue, ok := params.(map[string]interface{})[p[0]]; ok {
|
||||
return GetParameter(p[1], pValue)
|
||||
}
|
||||
} else {
|
||||
return nil, false
|
||||
}
|
||||
} else {
|
||||
if pValue, ok := params.(map[string]interface{})[p[0]]; ok {
|
||||
return pValue, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ExtractParameterAsString extracts value from interface{} as string based on the passed string
|
||||
func ExtractParameterAsString(s string, params interface{}) (string, bool) {
|
||||
if pValue, ok := GetParameter(s, params); ok {
|
||||
return fmt.Sprintf("%v", pValue), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Argument type specifies the parameter key name and the source it should
|
||||
// be extracted from
|
||||
type Argument struct {
|
||||
Source string `json:"source"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
EnvName string `json:"envname,omitempty"`
|
||||
}
|
||||
|
||||
// Get Argument method returns the value for the Argument's key name
|
||||
// based on the Argument's source
|
||||
func (ha *Argument) Get(headers, query, payload *map[string]interface{}) (string, bool) {
|
||||
var source *map[string]interface{}
|
||||
key := ha.Name
|
||||
|
||||
switch ha.Source {
|
||||
case SourceHeader:
|
||||
source = headers
|
||||
case SourceQuery:
|
||||
key = textproto.CanonicalMIMEHeaderKey(ha.Name)
|
||||
case SourceQuery, SourceQueryAlias:
|
||||
source = query
|
||||
case SourcePayload:
|
||||
source = payload
|
||||
case SourceString:
|
||||
return ha.Name, true
|
||||
case SourceEntirePayload:
|
||||
r, err := json.Marshal(payload)
|
||||
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return string(r), true
|
||||
case SourceEntireHeaders:
|
||||
r, err := json.Marshal(headers)
|
||||
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return string(r), true
|
||||
case SourceEntireQuery:
|
||||
r, err := json.Marshal(query)
|
||||
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return string(r), true
|
||||
}
|
||||
|
||||
if source != nil {
|
||||
return helpers.ExtractParameter(ha.Name, *source)
|
||||
return ExtractParameterAsString(key, *source)
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Header is a structure containing header name and it's value
|
||||
type Header struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// ResponseHeaders is a slice of Header objects
|
||||
type ResponseHeaders []Header
|
||||
|
||||
func (h *ResponseHeaders) String() string {
|
||||
// a 'hack' to display name=value in flag usage listing
|
||||
if len(*h) == 0 {
|
||||
return "name=value"
|
||||
}
|
||||
|
||||
result := make([]string, len(*h))
|
||||
|
||||
for idx, responseHeader := range *h {
|
||||
result[idx] = fmt.Sprintf("%s=%s", responseHeader.Name, responseHeader.Value)
|
||||
}
|
||||
|
||||
return strings.Join(result, ", ")
|
||||
}
|
||||
|
||||
// Set method appends new Header object from header=value notation
|
||||
func (h *ResponseHeaders) Set(value string) error {
|
||||
splitResult := strings.SplitN(value, "=", 2)
|
||||
|
||||
if len(splitResult) != 2 {
|
||||
return errors.New("header flag must be in name=value format")
|
||||
}
|
||||
|
||||
*h = append(*h, Header{Name: splitResult[0], Value: splitResult[1]})
|
||||
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"`
|
||||
ExecuteCommand string `json:"execute-command"`
|
||||
CommandWorkingDirectory string `json:"command-working-directory"`
|
||||
PassArgumentsToCommand []Argument `json:"pass-arguments-to-command"`
|
||||
TriggerRule *Rules `json:"trigger-rule"`
|
||||
ID string `json:"id,omitempty"`
|
||||
ExecuteCommand string `json:"execute-command,omitempty"`
|
||||
CommandWorkingDirectory string `json:"command-working-directory,omitempty"`
|
||||
ResponseMessage string `json:"response-message,omitempty"`
|
||||
ResponseHeaders ResponseHeaders `json:"response-headers,omitempty"`
|
||||
CaptureCommandOutput bool `json:"include-command-output-in-response,omitempty"`
|
||||
PassEnvironmentToCommand []Argument `json:"pass-environment-to-command,omitempty"`
|
||||
PassArgumentsToCommand []Argument `json:"pass-arguments-to-command,omitempty"`
|
||||
JSONStringParameters []Argument `json:"parse-parameters-as-json,omitempty"`
|
||||
TriggerRule *Rules `json:"trigger-rule,omitempty"`
|
||||
TriggerRuleMismatchHttpResponseCode int `json:"trigger-rule-mismatch-http-response-code,omitempty"`
|
||||
}
|
||||
|
||||
// ParseJSONParameters decodes specified arguments to JSON objects and replaces the
|
||||
// string with the newly created object
|
||||
func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface{}) []error {
|
||||
var errors = make([]error, 0)
|
||||
|
||||
for i := range h.JSONStringParameters {
|
||||
if arg, ok := h.JSONStringParameters[i].Get(headers, query, payload); ok {
|
||||
var newArg map[string]interface{}
|
||||
|
||||
decoder := json.NewDecoder(strings.NewReader(string(arg)))
|
||||
decoder.UseNumber()
|
||||
|
||||
err := decoder.Decode(&newArg)
|
||||
|
||||
if err != nil {
|
||||
errors = append(errors, &ParseError{err})
|
||||
continue
|
||||
}
|
||||
|
||||
var source *map[string]interface{}
|
||||
|
||||
switch h.JSONStringParameters[i].Source {
|
||||
case SourceHeader:
|
||||
source = headers
|
||||
case SourcePayload:
|
||||
source = payload
|
||||
case SourceQuery, SourceQueryAlias:
|
||||
source = query
|
||||
}
|
||||
|
||||
if source != nil {
|
||||
key := h.JSONStringParameters[i].Name
|
||||
|
||||
if h.JSONStringParameters[i].Source == SourceHeader {
|
||||
key = textproto.CanonicalMIMEHeaderKey(h.JSONStringParameters[i].Name)
|
||||
}
|
||||
|
||||
ReplaceParameter(key, source, newArg)
|
||||
} else {
|
||||
errors = append(errors, &SourceError{h.JSONStringParameters[i]})
|
||||
}
|
||||
} else {
|
||||
errors = append(errors, &ArgumentError{h.JSONStringParameters[i]})
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return errors
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtractCommandArguments creates a list of arguments, based on the
|
||||
// PassArgumentsToCommand property that is ready to be used with exec.Command()
|
||||
func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]interface{}) []string {
|
||||
func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]interface{}) ([]string, []error) {
|
||||
var args = make([]string, 0)
|
||||
var errors = make([]error, 0)
|
||||
|
||||
args = append(args, h.ExecuteCommand)
|
||||
|
||||
@@ -65,11 +449,42 @@ func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]inter
|
||||
args = append(args, arg)
|
||||
} else {
|
||||
args = append(args, "")
|
||||
log.Printf("couldn't retrieve argument for %+v\n", h.PassArgumentsToCommand[i])
|
||||
errors = append(errors, &ArgumentError{h.PassArgumentsToCommand[i]})
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
if len(errors) > 0 {
|
||||
return args, errors
|
||||
}
|
||||
|
||||
return args, nil
|
||||
}
|
||||
|
||||
// ExtractCommandArgumentsForEnv creates a list of arguments in key=value
|
||||
// format, based on the PassEnvironmentToCommand property that is ready to be used
|
||||
// with exec.Command().
|
||||
func (h *Hook) ExtractCommandArgumentsForEnv(headers, query, payload *map[string]interface{}) ([]string, []error) {
|
||||
var args = make([]string, 0)
|
||||
var errors = make([]error, 0)
|
||||
for i := range h.PassEnvironmentToCommand {
|
||||
if arg, ok := h.PassEnvironmentToCommand[i].Get(headers, query, payload); ok {
|
||||
if h.PassEnvironmentToCommand[i].EnvName != "" {
|
||||
// first try to use the EnvName if specified
|
||||
args = append(args, h.PassEnvironmentToCommand[i].EnvName+"="+arg)
|
||||
} else {
|
||||
// then fallback on the name
|
||||
args = append(args, EnvNamespace+h.PassEnvironmentToCommand[i].Name+"="+arg)
|
||||
}
|
||||
} else {
|
||||
errors = append(errors, &ArgumentError{h.PassEnvironmentToCommand[i]})
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return args, errors
|
||||
}
|
||||
|
||||
return args, nil
|
||||
}
|
||||
|
||||
// Hooks is an array of Hook objects
|
||||
@@ -92,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 {
|
||||
@@ -106,109 +534,120 @@ func (h *Hooks) Match(id string) *Hook {
|
||||
|
||||
// Rules is a structure that contains one of the valid rule types
|
||||
type Rules struct {
|
||||
And *AndRule `json:"and"`
|
||||
Or *OrRule `json:"or"`
|
||||
Not *NotRule `json:"not"`
|
||||
Match *MatchRule `json:"match"`
|
||||
And *AndRule `json:"and,omitempty"`
|
||||
Or *OrRule `json:"or,omitempty"`
|
||||
Not *NotRule `json:"not,omitempty"`
|
||||
Match *MatchRule `json:"match,omitempty"`
|
||||
}
|
||||
|
||||
// Evaluate finds the first rule property that is not nil and returns the value
|
||||
// it evaluates to
|
||||
func (r Rules) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) bool {
|
||||
func (r Rules) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte, 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
|
||||
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, remoteAddr string) (bool, error) {
|
||||
res := true
|
||||
|
||||
for _, v := range r {
|
||||
res = res && v.Evaluate(headers, query, payload, body)
|
||||
rv, err := v.Evaluate(headers, query, payload, body, remoteAddr)
|
||||
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, remoteAddr string) (bool, error) {
|
||||
res := false
|
||||
|
||||
for _, v := range r {
|
||||
res = res || v.Evaluate(headers, query, payload, body)
|
||||
rv, err := v.Evaluate(headers, query, payload, body, remoteAddr)
|
||||
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 !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
|
||||
}
|
||||
|
||||
// MatchRule will evaluate to true based on the type
|
||||
type MatchRule struct {
|
||||
Type string `json:"type"`
|
||||
Regex string `json:"regex"`
|
||||
Secret string `json:"secret"`
|
||||
Value string `json:"value"`
|
||||
Parameter Argument `json:"parameter"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Regex string `json:"regex,omitempty"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Parameter Argument `json:"parameter,omitempty"`
|
||||
IPRange string `json:"ip-range,omitempty"`
|
||||
}
|
||||
|
||||
// Constants for the MatchRule type
|
||||
const (
|
||||
MatchValue string = "value"
|
||||
MatchRegex string = "regex"
|
||||
MatchHashSHA1 string = "payload-hash-sha1"
|
||||
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 {
|
||||
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:
|
||||
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 := helpers.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
|
||||
case MatchHashSHA256:
|
||||
_, err := CheckPayloadSignature256(*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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var checkPayloadSignatureTests = []struct {
|
||||
payload []byte
|
||||
secret string
|
||||
signature string
|
||||
mac string
|
||||
ok bool
|
||||
}{
|
||||
{[]byte(`{"a": "z"}`), "secret", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", true},
|
||||
{[]byte(`{"a": "z"}`), "secret", "sha1=b17e04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", true},
|
||||
// failures
|
||||
{[]byte(`{"a": "z"}`), "secret", "XXXe04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", false},
|
||||
{[]byte(`{"a": "z"}`), "secreX", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", "900225703e9342328db7307692736e2f7cc7b36e", false},
|
||||
}
|
||||
|
||||
func TestCheckPayloadSignature(t *testing.T) {
|
||||
for _, tt := range checkPayloadSignatureTests {
|
||||
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))
|
||||
}
|
||||
|
||||
if err != nil && strings.Contains(err.Error(), tt.mac) {
|
||||
t.Errorf("error message should not disclose expected mac: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
value string
|
||||
ok bool
|
||||
}{
|
||||
{"a", map[string]interface{}{"a": "z"}, "z", true},
|
||||
{"a.b", map[string]interface{}{"a": map[string]interface{}{"b": "z"}}, "z", true},
|
||||
{"a.b.c", map[string]interface{}{"a": map[string]interface{}{"b": map[string]interface{}{"c": "z"}}}, "z", true},
|
||||
{"a.b.0", map[string]interface{}{"a": map[string]interface{}{"b": []interface{}{"x", "y", "z"}}}, "x", true},
|
||||
{"a.1.b", map[string]interface{}{"a": []interface{}{map[string]interface{}{"b": "y"}, map[string]interface{}{"b": "z"}}}, "z", true},
|
||||
{"a.1.b.c", map[string]interface{}{"a": []interface{}{map[string]interface{}{"b": map[string]interface{}{"c": "y"}}, map[string]interface{}{"b": map[string]interface{}{"c": "z"}}}}, "z", true},
|
||||
// failures
|
||||
{"check_nil", nil, "", false},
|
||||
{"a.X", map[string]interface{}{"a": map[string]interface{}{"b": "z"}}, "", false}, // non-existent parameter reference
|
||||
{"a.X.c", map[string]interface{}{"a": []interface{}{map[string]interface{}{"b": "y"}, map[string]interface{}{"b": "z"}}}, "", false}, // non-integer slice index
|
||||
{"a.-1.b", map[string]interface{}{"a": []interface{}{map[string]interface{}{"b": "y"}, map[string]interface{}{"b": "z"}}}, "", false}, // negative slice index
|
||||
{"a.500.b", map[string]interface{}{"a": map[string]interface{}{"b": "z"}}, "", false}, // non-existent slice
|
||||
{"a.501.b", map[string]interface{}{"a": []interface{}{map[string]interface{}{"b": "y"}, map[string]interface{}{"b": "z"}}}, "", false}, // non-existent slice index
|
||||
{"a.502.b", map[string]interface{}{"a": []interface{}{}}, "", false}, // non-existent slice index
|
||||
{"a.b.503", map[string]interface{}{"a": map[string]interface{}{"b": []interface{}{"x", "y", "z"}}}, "", false}, // trailing, non-existent slice index
|
||||
{"a.b", interface{}("a"), "", false}, // non-map, non-slice input
|
||||
}
|
||||
|
||||
func TestExtractParameter(t *testing.T) {
|
||||
for _, tt := range extractParameterTests {
|
||||
value, ok := ExtractParameterAsString(tt.s, tt.params)
|
||||
if ok != tt.ok || value != tt.value {
|
||||
t.Errorf("failed to extract parameter %q:\nexpected {value:%#v, ok:%#v},\ngot {value:%#v, ok:%#v}", tt.s, tt.value, tt.ok, value, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var argumentGetTests = []struct {
|
||||
source, name string
|
||||
headers, query, payload *map[string]interface{}
|
||||
value string
|
||||
ok bool
|
||||
}{
|
||||
{"header", "a", &map[string]interface{}{"A": "z"}, nil, nil, "z", true},
|
||||
{"url", "a", nil, &map[string]interface{}{"a": "z"}, nil, "z", true},
|
||||
{"payload", "a", nil, nil, &map[string]interface{}{"a": "z"}, "z", true},
|
||||
{"string", "a", nil, nil, &map[string]interface{}{"a": "z"}, "a", true},
|
||||
// failures
|
||||
{"header", "a", nil, &map[string]interface{}{"a": "z"}, &map[string]interface{}{"a": "z"}, "", false}, // nil headers
|
||||
{"url", "a", &map[string]interface{}{"A": "z"}, nil, &map[string]interface{}{"a": "z"}, "", false}, // nil query
|
||||
{"payload", "a", &map[string]interface{}{"A": "z"}, &map[string]interface{}{"a": "z"}, nil, "", false}, // nil payload
|
||||
{"foo", "a", &map[string]interface{}{"A": "z"}, nil, nil, "", false}, // invalid source
|
||||
}
|
||||
|
||||
func TestArgumentGet(t *testing.T) {
|
||||
for _, tt := range argumentGetTests {
|
||||
a := Argument{tt.source, tt.name, ""}
|
||||
value, ok := a.Get(tt.headers, tt.query, tt.payload)
|
||||
if ok != tt.ok || value != tt.value {
|
||||
t.Errorf("failed to get {%q, %q}:\nexpected {value:%#v, ok:%#v},\ngot {value:%#v, ok:%#v}", tt.source, tt.name, tt.value, tt.ok, value, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, 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, 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}
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hookExtractCommandArgumentsTests = []struct {
|
||||
exec string
|
||||
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"}, true},
|
||||
// failures
|
||||
{"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, 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Here we test the extraction of env variables when the user defined a hook
|
||||
// with the "pass-environment-to-command" directive
|
||||
// we test both cases where the name of the data is used as the name of the
|
||||
// env key & the case where the hook definition sets the env var name to a
|
||||
// fixed value using the envname construct like so::
|
||||
// [
|
||||
// {
|
||||
// "id": "push",
|
||||
// "execute-command": "bb2mm",
|
||||
// "command-working-directory": "/tmp",
|
||||
// "pass-environment-to-command":
|
||||
// [
|
||||
// {
|
||||
// "source": "entire-payload",
|
||||
// "envname": "PAYLOAD"
|
||||
// },
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
var hookExtractCommandArgumentsForEnvTests = []struct {
|
||||
exec string
|
||||
args []Argument
|
||||
headers, query, payload *map[string]interface{}
|
||||
value []string
|
||||
ok bool
|
||||
}{
|
||||
// successes
|
||||
{
|
||||
"test",
|
||||
[]Argument{Argument{"header", "a", ""}},
|
||||
&map[string]interface{}{"A": "z"}, nil, nil,
|
||||
[]string{"HOOK_a=z"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"test",
|
||||
[]Argument{Argument{"header", "a", "MYKEY"}},
|
||||
&map[string]interface{}{"A": "z"}, nil, nil,
|
||||
[]string{"MYKEY=z"},
|
||||
true,
|
||||
},
|
||||
// failures
|
||||
{
|
||||
"fail",
|
||||
[]Argument{Argument{"payload", "a", ""}},
|
||||
&map[string]interface{}{"A": "z"}, nil, nil,
|
||||
[]string{},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
func TestHookExtractCommandArgumentsForEnv(t *testing.T) {
|
||||
for _, tt := range hookExtractCommandArgumentsForEnvTests {
|
||||
h := &Hook{ExecuteCommand: tt.exec, PassEnvironmentToCommand: tt.args}
|
||||
value, err := h.ExtractCommandArgumentsForEnv(tt.headers, tt.query, tt.payload)
|
||||
if (err == nil) != tt.ok || !reflect.DeepEqual(value, tt.value) {
|
||||
t.Errorf("failed to extract args for env {cmd=%q, args=%v}:\nexpected %#v, ok: %v\ngot %#v, ok: %v", tt.exec, tt.args, tt.value, tt.ok, value, (err == nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hooksLoadFromFileTests = []struct {
|
||||
path string
|
||||
ok bool
|
||||
}{
|
||||
{"../hooks.json.example", true},
|
||||
{"", true},
|
||||
// failures
|
||||
{"missing.json", false},
|
||||
}
|
||||
|
||||
func TestHooksLoadFromFile(t *testing.T) {
|
||||
for _, tt := range hooksLoadFromFileTests {
|
||||
h := &Hooks{}
|
||||
err := h.LoadFromFile(tt.path)
|
||||
if (err == nil) != tt.ok {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hooksMatchTests = []struct {
|
||||
id string
|
||||
hooks Hooks
|
||||
value *Hook
|
||||
}{
|
||||
{"a", Hooks{Hook{ID: "a"}}, &Hook{ID: "a"}},
|
||||
{"X", Hooks{Hook{ID: "a"}}, new(Hook)},
|
||||
}
|
||||
|
||||
func TestHooksMatch(t *testing.T) {
|
||||
for _, tt := range hooksMatchTests {
|
||||
value := tt.hooks.Match(tt.id)
|
||||
if reflect.DeepEqual(reflect.ValueOf(value), reflect.ValueOf(tt.value)) {
|
||||
t.Errorf("failed to match %q:\nexpected %#v,\ngot %#v", tt.id, tt.value, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var matchRuleTests = []struct {
|
||||
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
|
||||
// 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
|
||||
{"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, 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var andRuleTests = []struct {
|
||||
desc string // description of the test case
|
||||
rule AndRule
|
||||
headers, query, payload *map[string]interface{}
|
||||
body []byte
|
||||
ok bool
|
||||
err bool
|
||||
}{
|
||||
{
|
||||
"(a=z, b=y): a=z && b=y",
|
||||
AndRule{
|
||||
{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,
|
||||
},
|
||||
{
|
||||
"(a=z, b=Y): a=z && b=y",
|
||||
AndRule{
|
||||
{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,
|
||||
},
|
||||
// Complex test to cover Rules.Evaluate
|
||||
{
|
||||
"(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", ""}, ""}},
|
||||
{
|
||||
And: &AndRule{
|
||||
{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", ""}, ""}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Not: &NotRule{
|
||||
Match: &MatchRule{"value", "", "", "u", Argument{"header", "f", ""}, ""},
|
||||
},
|
||||
},
|
||||
},
|
||||
&map[string]interface{}{"A": "z", "B": "y", "C": "x", "D": "w", "E": "X", "F": "X"}, nil, nil, []byte{},
|
||||
true, false,
|
||||
},
|
||||
{"empty rule", AndRule{{}}, nil, nil, nil, nil, false, false},
|
||||
// failures
|
||||
{
|
||||
"invalid rule",
|
||||
AndRule{{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", ""}, ""}}},
|
||||
&map[string]interface{}{"Y": "z"}, nil, nil, nil,
|
||||
false, false,
|
||||
},
|
||||
}
|
||||
|
||||
func TestAndRule(t *testing.T) {
|
||||
for _, tt := range andRuleTests {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var orRuleTests = []struct {
|
||||
desc string // description of the test case
|
||||
rule OrRule
|
||||
headers, query, payload *map[string]interface{}
|
||||
body []byte
|
||||
ok bool
|
||||
err bool
|
||||
}{
|
||||
{
|
||||
"(a=z, b=X): a=z || b=y",
|
||||
OrRule{
|
||||
{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,
|
||||
},
|
||||
{
|
||||
"(a=X, b=y): a=z || b=y",
|
||||
OrRule{
|
||||
{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,
|
||||
},
|
||||
{
|
||||
"(a=Z, b=Y): a=z || b=y",
|
||||
OrRule{
|
||||
{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,
|
||||
},
|
||||
// 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, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var notRuleTests = []struct {
|
||||
desc string // description of the test case
|
||||
rule NotRule
|
||||
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, 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, "")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,14 @@
|
||||
"id": "webhook",
|
||||
"execute-command": "/home/adnan/redeploy-go-webhook.sh",
|
||||
"command-working-directory": "/home/adnan/go",
|
||||
"response-message": "I got the payload!",
|
||||
"response-headers":
|
||||
[
|
||||
{
|
||||
"name": "Access-Control-Allow-Origin",
|
||||
"value": "*"
|
||||
}
|
||||
],
|
||||
"pass-arguments-to-command":
|
||||
[
|
||||
{
|
||||
|
||||
+34
@@ -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")
|
||||
|
||||
reloadAllHooks()
|
||||
} else {
|
||||
log.Printf("caught unhandled signal %+v\n", sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// +build windows
|
||||
|
||||
package main
|
||||
|
||||
func setupSignals() {
|
||||
// NOOP: Windows doesn't have signals equivalent to the Unix world.
|
||||
}
|
||||
@@ -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, " "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
[
|
||||
{
|
||||
"id": "github",
|
||||
"execute-command": "{{ .Hookecho }}",
|
||||
"command-working-directory": "/",
|
||||
"include-command-output-in-response": true,
|
||||
"trigger-rule-mismatch-http-response-code": 400,
|
||||
"pass-environment-to-command":
|
||||
[
|
||||
{
|
||||
"source": "payload",
|
||||
"name": "head_commit.timestamp"
|
||||
}
|
||||
],
|
||||
"pass-arguments-to-command":
|
||||
[
|
||||
{
|
||||
"source": "payload",
|
||||
"name": "head_commit.id"
|
||||
},
|
||||
{
|
||||
"source": "payload",
|
||||
"name": "head_commit.author.email"
|
||||
}
|
||||
],
|
||||
"trigger-rule":
|
||||
{
|
||||
"and":
|
||||
[
|
||||
{
|
||||
"match":
|
||||
{
|
||||
"type": "payload-hash-sha1",
|
||||
"secret": "mysecret",
|
||||
"parameter":
|
||||
{
|
||||
"source": "header",
|
||||
"name": "X-Hub-Signature"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"match":
|
||||
{
|
||||
"type": "value",
|
||||
"value": "refs/heads/master",
|
||||
"parameter":
|
||||
{
|
||||
"source": "payload",
|
||||
"name": "ref"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bitbucket",
|
||||
"execute-command": "{{ .Hookecho }}",
|
||||
"command-working-directory": "/",
|
||||
"include-command-output-in-response": false,
|
||||
"response-message": "success",
|
||||
"trigger-rule-mismatch-http-response-code": 999,
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
+342
-55
@@ -12,31 +12,73 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/adnanh/webhook/helpers"
|
||||
"github.com/adnanh/webhook/hook"
|
||||
|
||||
"github.com/codegangsta/negroni"
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
fsnotify "gopkg.in/fsnotify.v1"
|
||||
)
|
||||
|
||||
const (
|
||||
version = "2.0.0"
|
||||
version = "2.6.3"
|
||||
)
|
||||
|
||||
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")
|
||||
hooksFilePath = flag.String("hooks", "hooks.json", "path to the json file containing defined hooks the webhook should serve")
|
||||
ip = flag.String("ip", "0.0.0.0", "ip the webhook should serve hooks on")
|
||||
port = flag.Int("port", 9000, "port the webhook should serve hooks on")
|
||||
verbose = flag.Bool("verbose", false, "show verbose output")
|
||||
noPanic = flag.Bool("nopanic", false, "do not panic if hooks cannot be loaded when webhook is not running in verbose mode")
|
||||
hotReload = flag.Bool("hotreload", false, "watch hooks file for changes and reload them automatically")
|
||||
hooksURLPrefix = flag.String("urlprefix", "hooks", "url prefix to use for served hooks (protocol://yourserver:port/PREFIX/:hook-id)")
|
||||
secure = flag.Bool("secure", false, "use HTTPS instead of HTTP")
|
||||
cert = flag.String("cert", "cert.pem", "path to the HTTPS certificate pem file")
|
||||
key = flag.String("key", "key.pem", "path to the HTTPS certificate private key pem file")
|
||||
justDisplayVersion = flag.Bool("version", false, "display webhook version and quit")
|
||||
|
||||
hooks hook.Hooks
|
||||
responseHeaders hook.ResponseHeaders
|
||||
hooksFiles hook.HooksFiles
|
||||
|
||||
loadedHooksFromFiles = make(map[string]hook.Hooks)
|
||||
|
||||
watcher *fsnotify.Watcher
|
||||
signals chan os.Signal
|
||||
)
|
||||
|
||||
func init() {
|
||||
hooks = hook.Hooks{}
|
||||
func matchLoadedHook(id string) *hook.Hook {
|
||||
for _, hooks := range loadedHooksFromFiles {
|
||||
if hook := hooks.Match(id); hook != nil {
|
||||
return hook
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func lenLoadedHooks() int {
|
||||
sum := 0
|
||||
for _, hooks := range loadedHooksFromFiles {
|
||||
sum += len(hooks)
|
||||
}
|
||||
|
||||
return sum
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Var(&hooksFiles, "hooks", "path to the json file containing defined hooks the webhook should serve, use multiple times to load from different files")
|
||||
flag.Var(&responseHeaders, "header", "response header to return, specified in format name=value, use multiple times to set multiple headers")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if *justDisplayVersion {
|
||||
fmt.Println("webhook version " + version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if len(hooksFiles) == 0 {
|
||||
hooksFiles = append(hooksFiles, "hooks.json")
|
||||
}
|
||||
|
||||
log.SetPrefix("[webhook] ")
|
||||
log.SetFlags(log.Ldate | log.Ltime)
|
||||
|
||||
@@ -46,54 +88,113 @@ func init() {
|
||||
|
||||
log.Println("version " + version + " starting")
|
||||
|
||||
// set os signal watcher
|
||||
setupSignals()
|
||||
|
||||
// load and parse hooks
|
||||
log.Printf("attempting to load hooks from %s\n", *hooksFilePath)
|
||||
for _, hooksFilePath := range hooksFiles {
|
||||
log.Printf("attempting to load hooks from %s\n", hooksFilePath)
|
||||
|
||||
err := hooks.LoadFromFile(*hooksFilePath)
|
||||
newHooks := hook.Hooks{}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("couldn't load hooks from file! %+v\n", err)
|
||||
} else {
|
||||
log.Printf("loaded %d hook(s) from file\n", len(hooks))
|
||||
err := newHooks.LoadFromFile(hooksFilePath)
|
||||
|
||||
for _, hook := range hooks {
|
||||
log.Printf("\t> %s\n", hook.ID)
|
||||
if err != nil {
|
||||
log.Printf("couldn't load hooks from file! %+v\n", err)
|
||||
} else {
|
||||
log.Printf("found %d hook(s) in file\n", len(newHooks))
|
||||
|
||||
for _, hook := range newHooks {
|
||||
if matchLoadedHook(hook.ID) != nil {
|
||||
log.Fatalf("error: hook with the id %s has already been loaded!\nplease check your hooks file for duplicate hooks ids!\n", hook.ID)
|
||||
}
|
||||
log.Printf("\tloaded: %s\n", hook.ID)
|
||||
}
|
||||
|
||||
loadedHooksFromFiles[hooksFilePath] = newHooks
|
||||
}
|
||||
}
|
||||
// set up file watcher
|
||||
//log.Printf("setting up file watcher for %s\n", *hooksFilePath)
|
||||
}
|
||||
|
||||
func main() {
|
||||
l := log.New(os.Stdout, "[webhook] ", log.Ldate|log.Ltime)
|
||||
newHooksFiles := hooksFiles[:0]
|
||||
for _, filePath := range hooksFiles {
|
||||
if _, ok := loadedHooksFromFiles[filePath]; ok == true {
|
||||
newHooksFiles = append(newHooksFiles, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
negroniLogger := &negroni.Logger{l}
|
||||
hooksFiles = newHooksFiles
|
||||
|
||||
if !*verbose && !*noPanic && lenLoadedHooks() == 0 {
|
||||
log.SetOutput(os.Stdout)
|
||||
log.Fatalln("couldn't load any hooks from file!\naborting webhook execution since the -verbose flag is set to false.\nIf, for some reason, you want webhook to start without the hooks, either use -verbose flag, or -nopanic")
|
||||
}
|
||||
|
||||
if *hotReload {
|
||||
var err error
|
||||
|
||||
watcher, err = fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log.Fatal("error creating file watcher instance\n", err)
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
for _, hooksFilePath := range hooksFiles {
|
||||
// set up file watcher
|
||||
log.Printf("setting up file watcher for %s\n", hooksFilePath)
|
||||
|
||||
err = watcher.Add(hooksFilePath)
|
||||
if err != nil {
|
||||
log.Fatal("error adding hooks file to the watcher\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
go watchForFileChange()
|
||||
}
|
||||
|
||||
l := negroni.NewLogger()
|
||||
l.ALogger = log.New(os.Stderr, "[webhook] ", log.Ldate|log.Ltime)
|
||||
|
||||
negroniRecovery := &negroni.Recovery{
|
||||
Logger: l,
|
||||
Logger: l.ALogger,
|
||||
PrintStack: true,
|
||||
StackAll: false,
|
||||
StackSize: 1024 * 8,
|
||||
}
|
||||
|
||||
n := negroni.New(negroniRecovery, negroniLogger)
|
||||
n := negroni.New(negroniRecovery, l)
|
||||
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/hooks/{id}", hookHandler)
|
||||
|
||||
var hooksURL string
|
||||
|
||||
if *hooksURLPrefix == "" {
|
||||
hooksURL = "/{id}"
|
||||
} else {
|
||||
hooksURL = "/" + *hooksURLPrefix + "/{id}"
|
||||
}
|
||||
|
||||
router.HandleFunc(hooksURL, hookHandler)
|
||||
|
||||
n.UseHandler(router)
|
||||
|
||||
log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%d", *ip, *port), n))
|
||||
if *secure {
|
||||
log.Printf("serving hooks on https://%s:%d%s", *ip, *port, hooksURL)
|
||||
log.Fatal(http.ListenAndServeTLS(fmt.Sprintf("%s:%d", *ip, *port), *cert, *key, n))
|
||||
} else {
|
||||
log.Printf("serving hooks on http://%s:%d%s", *ip, *port, hooksURL)
|
||||
log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%d", *ip, *port), n))
|
||||
}
|
||||
|
||||
log.Printf("listening on %s:%d", *ip, *port)
|
||||
}
|
||||
|
||||
func hookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
for _, responseHeader := range responseHeaders {
|
||||
w.Header().Set(responseHeader.Name, responseHeader.Value)
|
||||
}
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
|
||||
hook := hooks.Match(id)
|
||||
|
||||
if hook != nil {
|
||||
if matchedHook := matchLoadedHook(id); matchedHook != nil {
|
||||
log.Printf("%s got matched\n", id)
|
||||
|
||||
body, err := ioutil.ReadAll(r.Body)
|
||||
@@ -102,17 +203,17 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// parse headers
|
||||
headers := helpers.ValuesToMap(r.Header)
|
||||
headers := valuesToMap(r.Header)
|
||||
|
||||
// parse query variables
|
||||
query := helpers.ValuesToMap(r.URL.Query())
|
||||
query := valuesToMap(r.URL.Query())
|
||||
|
||||
// parse body
|
||||
var payload map[string]interface{}
|
||||
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
|
||||
if contentType == "application/json" {
|
||||
if strings.Contains(contentType, "json") {
|
||||
decoder := json.NewDecoder(strings.NewReader(string(body)))
|
||||
decoder.UseNumber()
|
||||
|
||||
@@ -121,44 +222,230 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
log.Printf("error parsing JSON payload %+v\n", err)
|
||||
}
|
||||
} else if contentType == "application/x-www-form-urlencoded" {
|
||||
} 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 = helpers.ValuesToMap(fd)
|
||||
payload = valuesToMap(fd)
|
||||
}
|
||||
}
|
||||
|
||||
// handle hook
|
||||
go handleHook(hook, &headers, &query, &payload, &body)
|
||||
if errors := matchedHook.ParseJSONParameters(&headers, &query, &payload); errors != nil {
|
||||
for _, err := range errors {
|
||||
log.Printf("error parsing JSON parameters: %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// say thanks
|
||||
fmt.Fprintf(w, "Thanks.")
|
||||
var ok bool
|
||||
|
||||
if matchedHook.TriggerRule == nil {
|
||||
ok = true
|
||||
} else {
|
||||
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)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, "Error occurred while evaluating hook rules.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if ok {
|
||||
log.Printf("%s hook triggered successfully\n", matchedHook.ID)
|
||||
|
||||
for _, responseHeader := range matchedHook.ResponseHeaders {
|
||||
w.Header().Set(responseHeader.Name, responseHeader.Value)
|
||||
}
|
||||
|
||||
if matchedHook.CaptureCommandOutput {
|
||||
response, err := handleHook(matchedHook, &headers, &query, &payload, &body)
|
||||
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, "Error occurred while executing the hook's command. Please check your logs for more details.")
|
||||
} else {
|
||||
fmt.Fprintf(w, response)
|
||||
}
|
||||
} else {
|
||||
go handleHook(matchedHook, &headers, &query, &payload, &body)
|
||||
fmt.Fprintf(w, matchedHook.ResponseMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if a return code is configured for the hook
|
||||
if matchedHook.TriggerRuleMismatchHttpResponseCode != 0 {
|
||||
// Check if the configured return code is supported by the http package
|
||||
// by testing if there is a StatusText for this code.
|
||||
if len(http.StatusText(matchedHook.TriggerRuleMismatchHttpResponseCode)) > 0 {
|
||||
w.WriteHeader(matchedHook.TriggerRuleMismatchHttpResponseCode)
|
||||
} else {
|
||||
log.Printf("%s got matched, but the configured return code %d is unknown - defaulting to 200\n", matchedHook.ID, matchedHook.TriggerRuleMismatchHttpResponseCode)
|
||||
}
|
||||
}
|
||||
|
||||
// if none of the hooks got triggered
|
||||
log.Printf("%s got matched, but didn't get triggered because the trigger rules were not satisfied\n", matchedHook.ID)
|
||||
|
||||
fmt.Fprintf(w, "Hook rules were not satisfied.")
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, "Hook not found.")
|
||||
}
|
||||
}
|
||||
|
||||
func handleHook(hook *hook.Hook, headers, query, payload *map[string]interface{}, body *[]byte) {
|
||||
if hook.TriggerRule == nil || hook.TriggerRule != nil && hook.TriggerRule.Evaluate(headers, query, payload, body) {
|
||||
log.Printf("%s hook triggered successfully\n", hook.ID)
|
||||
func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, body *[]byte) (string, error) {
|
||||
var errors []error
|
||||
|
||||
cmd := exec.Command(hook.ExecuteCommand)
|
||||
cmd.Args = hook.ExtractCommandArguments(headers, query, payload)
|
||||
cmd.Dir = hook.CommandWorkingDirectory
|
||||
cmd := exec.Command(h.ExecuteCommand)
|
||||
cmd.Dir = h.CommandWorkingDirectory
|
||||
|
||||
log.Printf("executing %s (%s) with arguments %s using %s as cwd\n", hook.ExecuteCommand, cmd.Path, cmd.Args, cmd.Dir)
|
||||
|
||||
out, err := cmd.Output()
|
||||
|
||||
log.Printf("stdout: %s\n", out)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("stderr: %+v\n", err)
|
||||
cmd.Args, errors = h.ExtractCommandArguments(headers, query, payload)
|
||||
if errors != nil {
|
||||
for _, err := range errors {
|
||||
log.Printf("error extracting command arguments: %s\n", err)
|
||||
}
|
||||
log.Printf("finished handling %s\n", hook.ID)
|
||||
}
|
||||
|
||||
var envs []string
|
||||
envs, errors = h.ExtractCommandArgumentsForEnv(headers, query, payload)
|
||||
|
||||
if errors != nil {
|
||||
for _, err := range errors {
|
||||
log.Printf("error extracting command arguments for environment: %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
cmd.Env = append(os.Environ(), envs...)
|
||||
|
||||
log.Printf("executing %s (%s) with arguments %q and environment %s using %s as cwd\n", h.ExecuteCommand, cmd.Path, cmd.Args, envs, cmd.Dir)
|
||||
|
||||
out, err := cmd.Output()
|
||||
|
||||
log.Printf("command output: %s\n", out)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("error occurred: %+v\n", err)
|
||||
}
|
||||
|
||||
log.Printf("finished handling %s\n", h.ID)
|
||||
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func reloadHooks(hooksFilePath string) {
|
||||
hooksInFile := hook.Hooks{}
|
||||
|
||||
// parse and swap
|
||||
log.Printf("attempting to reload hooks from %s\n", hooksFilePath)
|
||||
|
||||
err := hooksInFile.LoadFromFile(hooksFilePath)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("couldn't load hooks from file! %+v\n", err)
|
||||
} else {
|
||||
log.Printf("%s hook did not get triggered\n", hook.ID)
|
||||
seenHooksIds := make(map[string]bool)
|
||||
|
||||
log.Printf("found %d hook(s) in file\n", len(hooksInFile))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func watchForFileChange() {
|
||||
for {
|
||||
select {
|
||||
case event := <-(*watcher).Events:
|
||||
if event.Op&fsnotify.Write == fsnotify.Write {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
+524
@@ -0,0 +1,524 @@
|
||||
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,
|
||||
`arg: 1481a2de7b2a7d02428ad93446ab166be7793fbb lolwut@noway.biz
|
||||
env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
|
||||
`,
|
||||
},
|
||||
{
|
||||
"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,
|
||||
`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,
|
||||
`arg: b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327 John Smith john@example.com
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
"missing-cmd-arg", // missing head_commit.author.email
|
||||
"github",
|
||||
map[string]string{"X-Hub-Signature": "ab03955b9377f530aa298b1b6d273ae9a47e1e40"},
|
||||
`{
|
||||
"head_commit":{
|
||||
"added":[
|
||||
"words/madame-bovary.txt"
|
||||
],
|
||||
"author":{
|
||||
"email":"lolwut@noway.biz",
|
||||
"name":"Garen Torikian",
|
||||
"username":"octokitty"
|
||||
},
|
||||
"committer":{
|
||||
"email":"lolwut@noway.biz",
|
||||
"name":"Garen Torikian",
|
||||
"username":"octokitty"
|
||||
},
|
||||
"distinct":true,
|
||||
"id":"1481a2de7b2a7d02428ad93446ab166be7793fbb",
|
||||
"message":"Rename madame-bovary.txt to words/madame-bovary.txt",
|
||||
"modified":[
|
||||
|
||||
],
|
||||
"removed":[
|
||||
"madame-bovary.txt"
|
||||
],
|
||||
"timestamp":"2013-03-12T08:14:29-07:00",
|
||||
"url":"https://github.com/octokitty/testing/commit/1481a2de7b2a7d02428ad93446ab166be7793fbb"
|
||||
},
|
||||
"ref":"refs/heads/master"
|
||||
}`,
|
||||
false,
|
||||
http.StatusOK,
|
||||
`arg: 1481a2de7b2a7d02428ad93446ab166be7793fbb lolwut@noway.biz
|
||||
env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
|
||||
`,
|
||||
},
|
||||
|
||||
{
|
||||
"missing-env-arg", // missing head_commit.timestamp
|
||||
"github",
|
||||
map[string]string{"X-Hub-Signature": "2cf8b878cb6b74a25090a140fa4a474be04b97fa"},
|
||||
`{
|
||||
"head_commit":{
|
||||
"added":[
|
||||
"words/madame-bovary.txt"
|
||||
],
|
||||
"author":{
|
||||
"email":"lolwut@noway.biz",
|
||||
"name":"Garen Torikian",
|
||||
"username":"octokitty"
|
||||
},
|
||||
"committer":{
|
||||
"email":"lolwut@noway.biz",
|
||||
"name":"Garen Torikian",
|
||||
"username":"octokitty"
|
||||
},
|
||||
"distinct":true,
|
||||
"id":"1481a2de7b2a7d02428ad93446ab166be7793fbb",
|
||||
"message":"Rename madame-bovary.txt to words/madame-bovary.txt",
|
||||
"modified":[
|
||||
|
||||
],
|
||||
"removed":[
|
||||
"madame-bovary.txt"
|
||||
],
|
||||
"url":"https://github.com/octokitty/testing/commit/1481a2de7b2a7d02428ad93446ab166be7793fbb"
|
||||
},
|
||||
"ref":"refs/heads/master"
|
||||
}`,
|
||||
false,
|
||||
http.StatusOK,
|
||||
`arg: 1481a2de7b2a7d02428ad93446ab166be7793fbb lolwut@noway.biz
|
||||
`,
|
||||
},
|
||||
|
||||
// test with custom return code
|
||||
{"empty payload", "github", nil, `{}`, false, http.StatusBadRequest, `Hook rules were not satisfied.`},
|
||||
// test with custom invalid http code, should default to 200 OK
|
||||
{"empty payload", "bitbucket", nil, `{}`, false, http.StatusOK, `Hook rules were not satisfied.`},
|
||||
// test with no configured http return code, should default to 200 OK
|
||||
{"empty payload", "gitlab", nil, `{}`, false, http.StatusOK, `Hook rules were not satisfied.`},
|
||||
}
|
||||
Reference in New Issue
Block a user