The commit adds the following modifications: * Possibility to load hooks recursively from a directory * Extraction of the loadinghook method in webhook.go * Tests done on a fairly complex tree of directory Allowing hooks to be loaded from a directory is a nice feature, as it allows a better organisation/readibility of the hooks that we have. The LoadFromDir method rely on LoadFromFile and is meant to be very permissive, and will return errors only when no hooks have been loaded. Otherwise it will simply return 'nil' and the potential issues/warnings that happen during the loading of hooks (invalid file, invalid path, issue opening, etc).
355 lines
9.1 KiB
Go
355 lines
9.1 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"github.com/adnanh/webhook/hook"
|
|
|
|
"github.com/codegangsta/negroni"
|
|
"github.com/gorilla/mux"
|
|
|
|
fsnotify "gopkg.in/fsnotify.v1"
|
|
)
|
|
|
|
const (
|
|
version = "2.3.8"
|
|
)
|
|
|
|
var (
|
|
ip = flag.String("ip", "", "ip the webhook should serve hooks on")
|
|
port = flag.Int("port", 9000, "port the webhook should serve hooks on")
|
|
verbose = flag.Bool("verbose", false, "show verbose output")
|
|
noPanic = flag.Bool("nopanic", false, "do not panic if hooks cannot be loaded when webhook is not running in verbose mode")
|
|
hotReload = flag.Bool("hotreload", false, "watch hooks file for changes and reload them automatically")
|
|
hooksFilePath = flag.String("hooks", "hooks.json", "path to the json file containing defined hooks the webhook should serve")
|
|
hooksDirPath = flag.String("hooksdir", "", "path to the json directory containing defined hooks the webhook should serve")
|
|
hooksURLPrefix = flag.String("urlprefix", "hooks", "url prefix to use for served hooks (protocol://yourserver:port/PREFIX/:hook-id)")
|
|
secure = flag.Bool("secure", false, "use HTTPS instead of HTTP")
|
|
cert = flag.String("cert", "cert.pem", "path to the HTTPS certificate pem file")
|
|
key = flag.String("key", "key.pem", "path to the HTTPS certificate private key pem file")
|
|
|
|
responseHeaders hook.ResponseHeaders
|
|
|
|
watcher *fsnotify.Watcher
|
|
signals chan os.Signal
|
|
|
|
hooks hook.Hooks
|
|
)
|
|
|
|
func main() {
|
|
hooks = hook.Hooks{}
|
|
|
|
flag.Var(&responseHeaders, "header", "response header to return, specified in format name=value, use multiple times to set multiple headers")
|
|
|
|
flag.Parse()
|
|
|
|
log.SetPrefix("[webhook] ")
|
|
log.SetFlags(log.Ldate | log.Ltime)
|
|
|
|
if !*verbose {
|
|
log.SetOutput(ioutil.Discard)
|
|
}
|
|
|
|
log.Println("version " + version + " starting")
|
|
|
|
// set os signal watcher
|
|
setupSignals()
|
|
|
|
// load and parse hooks
|
|
hooks = loadHooks()
|
|
|
|
if *hotReload {
|
|
// set up file watcher
|
|
log.Printf("setting up file watcher for %s\n", *hooksFilePath)
|
|
|
|
var err error
|
|
|
|
watcher, err = fsnotify.NewWatcher()
|
|
if err != nil {
|
|
log.Fatal("error creating file watcher instance", err)
|
|
}
|
|
|
|
defer watcher.Close()
|
|
|
|
go watchForFileChange()
|
|
|
|
err = watcher.Add(*hooksFilePath)
|
|
if err != nil {
|
|
log.Fatal("error adding hooks file to the watcher", err)
|
|
}
|
|
}
|
|
|
|
l := negroni.NewLogger()
|
|
l.Logger = log.New(os.Stderr, "[webhook] ", log.Ldate|log.Ltime)
|
|
|
|
negroniRecovery := &negroni.Recovery{
|
|
Logger: l.Logger,
|
|
PrintStack: true,
|
|
StackAll: false,
|
|
StackSize: 1024 * 8,
|
|
}
|
|
|
|
n := negroni.New(negroniRecovery, l)
|
|
|
|
router := mux.NewRouter()
|
|
|
|
var hooksURL string
|
|
|
|
if *hooksURLPrefix == "" {
|
|
hooksURL = "/{id}"
|
|
} else {
|
|
hooksURL = "/" + *hooksURLPrefix + "/{id}"
|
|
}
|
|
|
|
router.HandleFunc(hooksURL, hookHandler)
|
|
|
|
n.UseHandler(router)
|
|
|
|
if *secure {
|
|
log.Printf("starting secure (https) webhook on %s:%d", *ip, *port)
|
|
log.Fatal(http.ListenAndServeTLS(fmt.Sprintf("%s:%d", *ip, *port), *cert, *key, n))
|
|
} else {
|
|
log.Printf("starting insecure (http) webhook on %s:%d", *ip, *port)
|
|
log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%d", *ip, *port), n))
|
|
}
|
|
}
|
|
|
|
func loadHooks() hook.Hooks {
|
|
log.Printf("attempting to load hooks from file %s\n", *hooksFilePath)
|
|
file_hooks := hook.Hooks{}
|
|
err_loadfile := file_hooks.LoadFromFile(*hooksFilePath)
|
|
|
|
log.Printf("attempting to load hooks from dir %s\n", *hooksDirPath)
|
|
dir_hooks := hook.Hooks{}
|
|
warnings, err_loaddir := dir_hooks.LoadFromDir(*hooksDirPath)
|
|
|
|
if *hooksDirPath != "" && len(warnings) != 0 {
|
|
log.Printf("faced issues while loading from %s:\n", *hooksDirPath)
|
|
for _, warning := range warnings {
|
|
log.Printf("\t> %s\n", warning)
|
|
}
|
|
}
|
|
if err_loadfile != nil && err_loaddir != nil {
|
|
if !*verbose && !*noPanic {
|
|
log.SetOutput(os.Stdout)
|
|
log.Printf("couldn't load any hooks from file and/or dir!\n")
|
|
log.Printf("if, for some reason, you want webhook to start without the hooks, either use -verbose flag, or -nopanic")
|
|
log.Fatal("aborting webhook execution since the -verbose flag is set to false.\n")
|
|
}
|
|
} else {
|
|
log.Printf("loaded %d hook(s) from file\n", len(file_hooks))
|
|
for _, hook := range file_hooks {
|
|
log.Printf("\t> %s\n", hook.ID)
|
|
}
|
|
log.Printf("loaded %d hook(s) from directory\n", len(dir_hooks))
|
|
for _, hook := range dir_hooks {
|
|
log.Printf("\t> %s\n", hook.ID)
|
|
}
|
|
}
|
|
return append(dir_hooks, file_hooks...)
|
|
}
|
|
|
|
func hookHandler(w http.ResponseWriter, r *http.Request) {
|
|
for _, responseHeader := range responseHeaders {
|
|
w.Header().Set(responseHeader.Name, responseHeader.Value)
|
|
}
|
|
|
|
id := mux.Vars(r)["id"]
|
|
|
|
matchedHooks := hooks.MatchAll(id)
|
|
|
|
if matchedHooks != nil {
|
|
log.Printf("%s got matched (%d time(s))\n", id, len(matchedHooks))
|
|
|
|
body, err := ioutil.ReadAll(r.Body)
|
|
if err != nil {
|
|
log.Printf("error reading the request body. %+v\n", err)
|
|
}
|
|
|
|
// parse headers
|
|
headers := valuesToMap(r.Header)
|
|
|
|
// parse query variables
|
|
query := valuesToMap(r.URL.Query())
|
|
|
|
// parse body
|
|
var payload map[string]interface{}
|
|
|
|
contentType := r.Header.Get("Content-Type")
|
|
|
|
if strings.Contains(contentType, "json") {
|
|
decoder := json.NewDecoder(strings.NewReader(string(body)))
|
|
decoder.UseNumber()
|
|
|
|
err := decoder.Decode(&payload)
|
|
|
|
if err != nil {
|
|
log.Printf("error parsing JSON payload %+v\n", err)
|
|
}
|
|
} else if strings.Contains(contentType, "form") {
|
|
fd, err := url.ParseQuery(string(body))
|
|
if err != nil {
|
|
log.Printf("error parsing form payload %+v\n", err)
|
|
} else {
|
|
payload = valuesToMap(fd)
|
|
}
|
|
}
|
|
|
|
// handle hook
|
|
for _, h := range matchedHooks {
|
|
|
|
err := h.ParseJSONParameters(&headers, &query, &payload)
|
|
if err != nil {
|
|
msg := fmt.Sprintf("error parsing JSON: %s", err)
|
|
log.Printf(msg)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
fmt.Fprintf(w, msg)
|
|
return
|
|
}
|
|
|
|
var ok bool
|
|
|
|
if h.TriggerRule == nil {
|
|
ok = true
|
|
} else {
|
|
ok, err = h.TriggerRule.Evaluate(&headers, &query, &payload, &body)
|
|
if err != nil {
|
|
msg := fmt.Sprintf("error evaluating hook: %s", err)
|
|
log.Printf(msg)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
fmt.Fprintf(w, msg)
|
|
return
|
|
}
|
|
}
|
|
|
|
if ok {
|
|
log.Printf("%s hook triggered successfully\n", h.ID)
|
|
|
|
for _, responseHeader := range h.ResponseHeaders {
|
|
w.Header().Set(responseHeader.Name, responseHeader.Value)
|
|
}
|
|
|
|
if h.CaptureCommandOutput {
|
|
response := handleHook(h, &headers, &query, &payload, &body)
|
|
fmt.Fprintf(w, response)
|
|
} else {
|
|
go handleHook(h, &headers, &query, &payload, &body)
|
|
fmt.Fprintf(w, h.ResponseMessage)
|
|
}
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
// if none of the hooks got triggered
|
|
log.Printf("%s got matched (%d time(s)), but didn't get triggered because the trigger rules were not satisfied\n", matchedHooks[0].ID, len(matchedHooks))
|
|
|
|
fmt.Fprintf(w, "Hook rules were not satisfied.")
|
|
} else {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
fmt.Fprintf(w, "Hook not found.")
|
|
}
|
|
}
|
|
|
|
func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, body *[]byte) string {
|
|
var err error
|
|
|
|
cmd := exec.Command(h.ExecuteCommand)
|
|
cmd.Dir = h.CommandWorkingDirectory
|
|
|
|
cmd.Args, err = h.ExtractCommandArguments(headers, query, payload)
|
|
if err != nil {
|
|
log.Printf("error extracting command arguments: %s", err)
|
|
}
|
|
|
|
var envs []string
|
|
envs, err = h.ExtractCommandArgumentsForEnv(headers, query, payload)
|
|
if err != nil {
|
|
log.Printf("error extracting command arguments for environment: %s", err)
|
|
}
|
|
cmd.Env = append(os.Environ(), envs...)
|
|
|
|
log.Printf("executing %s (%s) with arguments %q and environment %s using %s as cwd\n", h.ExecuteCommand, cmd.Path, cmd.Args, envs, cmd.Dir)
|
|
|
|
out, err := cmd.CombinedOutput()
|
|
|
|
log.Printf("command output: %s\n", out)
|
|
|
|
var errorResponse string
|
|
|
|
if err != nil {
|
|
log.Printf("error occurred: %+v\n", err)
|
|
errorResponse = fmt.Sprintf("%+v", err)
|
|
}
|
|
|
|
log.Printf("finished handling %s\n", h.ID)
|
|
|
|
var response []byte
|
|
response, err = json.Marshal(&hook.CommandStatusResponse{ResponseMessage: h.ResponseMessage, Output: string(out), Error: errorResponse})
|
|
|
|
if err != nil {
|
|
log.Printf("error marshalling response: %+v", err)
|
|
return h.ResponseMessage
|
|
}
|
|
|
|
return string(response)
|
|
}
|
|
|
|
func reloadHooks() {
|
|
newHooks := hook.Hooks{}
|
|
|
|
// parse and swap
|
|
log.Printf("attempting to reload hooks from %s\n", *hooksFilePath)
|
|
|
|
err := newHooks.LoadFromFile(*hooksFilePath)
|
|
|
|
if err != nil {
|
|
log.Printf("couldn't load hooks from file! %+v\n", err)
|
|
} else {
|
|
log.Printf("loaded %d hook(s) from file\n", len(hooks))
|
|
|
|
for _, hook := range hooks {
|
|
log.Printf("\t> %s\n", hook.ID)
|
|
}
|
|
|
|
hooks = newHooks
|
|
}
|
|
}
|
|
|
|
func watchForFileChange() {
|
|
for {
|
|
select {
|
|
case event := <-(*watcher).Events:
|
|
if event.Op&fsnotify.Write == fsnotify.Write {
|
|
log.Println("hooks file modified")
|
|
|
|
reloadHooks()
|
|
}
|
|
case err := <-(*watcher).Errors:
|
|
log.Println("watcher error:", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// valuesToMap converts map[string][]string to a map[string]string object
|
|
func valuesToMap(values map[string][]string) map[string]interface{} {
|
|
ret := make(map[string]interface{})
|
|
|
|
for key, value := range values {
|
|
if len(value) > 0 {
|
|
ret[key] = value[0]
|
|
}
|
|
}
|
|
|
|
return ret
|
|
}
|