From b4780a41d3a9ff448d4b7dc052e2cac93d679f23 Mon Sep 17 00:00:00 2001 From: xenolf Date: Wed, 3 Jun 2015 01:29:33 +0200 Subject: [PATCH] Added webhook functionality to the git middleware. The webhook providers reside behind a small interface which determines if a provider should run. If a provider should run it delegates responsibility of the request to the provider. ghdeploy initial commit Added webhook functionality to the git middleware. The webhook providers reside behind a small interface which determines if a provider should run. If a provider should run it delegates responsibility of the request to the provider. Add tests Remove old implementation Fix inconsistency with git interval pulling. Remove '\n' from logging statements and put the initial pull into a startup function --- config/setup/git.go | 41 +++++- middleware/git/git.go | 3 + middleware/git/webhook/github_hook.go | 159 +++++++++++++++++++++ middleware/git/webhook/github_hook_test.go | 63 ++++++++ middleware/git/webhook/webhook.go | 43 ++++++ 5 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 middleware/git/webhook/github_hook.go create mode 100644 middleware/git/webhook/github_hook_test.go create mode 100644 middleware/git/webhook/webhook.go diff --git a/config/setup/git.go b/config/setup/git.go index 2e6c76ca..972ce82b 100644 --- a/config/setup/git.go +++ b/config/setup/git.go @@ -11,6 +11,7 @@ import ( "github.com/mholt/caddy/middleware" "github.com/mholt/caddy/middleware/git" + "github.com/mholt/caddy/middleware/git/webhook" ) // Git configures a new Git service routine. @@ -20,13 +21,30 @@ func Git(c *Controller) (middleware.Middleware, error) { return nil, err } - c.Startup = append(c.Startup, func() error { - // Start service routine in background - git.Start(repo) + // If a HookUrl is set, we switch to event based pulling. + // Install the url handler + if repo.HookUrl != "" { - // Do a pull right away to return error - return repo.Pull() - }) + c.Startup = append(c.Startup, func() error { + return repo.Pull() + }) + + webhook := &webhook.WebHook{Repo: repo} + return func(next middleware.Handler) middleware.Handler { + webhook.Next = next + return webhook + }, nil + + } else { + c.Startup = append(c.Startup, func() error { + + // Start service routine in background + git.Start(repo) + + // Do a pull right away to return error + return repo.Pull() + }) + } return nil, err } @@ -75,6 +93,17 @@ func gitParse(c *Controller) (*git.Repo, error) { if t > 0 { repo.Interval = time.Duration(t) * time.Second } + case "hook": + if !c.NextArg() { + return nil, c.ArgErr() + } + repo.HookUrl = c.Val() + + // optional secret for validation + if c.NextArg() { + repo.HookSecret = c.Val() + } + case "then": thenArgs := c.RemainingArgs() if len(thenArgs) == 0 { diff --git a/middleware/git/git.go b/middleware/git/git.go index 6695dab1..74db1bb7 100644 --- a/middleware/git/git.go +++ b/middleware/git/git.go @@ -59,6 +59,9 @@ type Repo struct { lastPull time.Time // time of the last successful pull lastCommit string // hash for the most recent commit sync.Mutex + HookUrl string // url to listen on for webhooks + HookSecret string // secret to validate hooks + } // Pull attempts a git clone. diff --git a/middleware/git/webhook/github_hook.go b/middleware/git/webhook/github_hook.go new file mode 100644 index 00000000..e3ca70fd --- /dev/null +++ b/middleware/git/webhook/github_hook.go @@ -0,0 +1,159 @@ +package webhook + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "github.com/mholt/caddy/middleware/git" + "io/ioutil" + "log" + "net/http" + "os" + "strings" +) + +type GithubHook struct{} + +type ghRelease struct { + Action string `json:"action"` + Release struct { + TagName string `json:"tag_name"` + Name interface{} `json:"name"` + } `json:"release"` +} + +type ghPush struct { + Ref string `json:"ref"` +} + +// Logger is used to log errors; if nil, the default log.Logger is used. +var Logger *log.Logger + +// logger is an helper function to retrieve the available logger +func logger() *log.Logger { + if Logger == nil { + Logger = log.New(os.Stderr, "", log.LstdFlags) + } + return Logger +} + +func (g GithubHook) DoesHandle(h http.Header) bool { + userAgent := h.Get("User-Agent") + + // GitHub always uses a user-agent like "GitHub-Hookshot/" + if userAgent != "" && strings.HasPrefix(userAgent, "GitHub-Hookshot") { + return true + } + return false +} + +func (g GithubHook) Handle(w http.ResponseWriter, r *http.Request, repo *git.Repo) (int, error) { + if r.Method != "POST" { + return http.StatusMethodNotAllowed, errors.New("The request had an invalid method.") + } + + // read full body - required for signature + body, err := ioutil.ReadAll(r.Body) + + err = g.handleSignature(r, body, repo.HookSecret) + if err != nil { + return http.StatusBadRequest, err + } + + event := r.Header.Get("X-Github-Event") + if event == "" { + return http.StatusBadRequest, errors.New("The 'X-Github-Event' header is required but was missing.") + } + + switch event { + case "ping": + w.Write([]byte("pong")) + case "push": + err := g.handlePush(body, repo) + if err != nil { + return http.StatusBadRequest, err + } + + case "release": + err := g.handleRelease(body, repo) + if err != nil { + return http.StatusBadRequest, err + } + + // return 400 if we do not handle the event type. + // This is to visually show the user a configuration error in the GH ui. + default: + return http.StatusBadRequest, nil + } + + return http.StatusOK, nil +} + +// Check for an optional signature in the request +// if it is signed, verify the signature. +func (g GithubHook) handleSignature(r *http.Request, body []byte, secret string) error { + signature := r.Header.Get("X-Hub-Signature") + if signature != "" { + if secret == "" { + logger().Print("Unable to verify request signature. Secret not set in caddyfile!") + } else { + mac := hmac.New(sha1.New, []byte(secret)) + mac.Write(body) + expectedMac := hex.EncodeToString(mac.Sum(nil)) + + if signature[5:] != expectedMac { + return errors.New("Could not verify request signature. The signature is invalid!") + } + } + } + + return nil +} + +func (g GithubHook) handlePush(body []byte, repo *git.Repo) error { + var push ghPush + + err := json.Unmarshal(body, &push) + if err != nil { + return err + } + + // extract the branch being pushed from the ref string + // and if it matches with our locally tracked one, pull. + refSlice := strings.Split(push.Ref, "/") + if len(refSlice) != 3 { + return errors.New("The push request contained an invalid reference string.") + } + + branch := refSlice[2] + if branch == repo.Branch { + logger().Print("Received pull notification for the tracking branch, updating...") + repo.Pull() + } + + return nil +} + +func (g GithubHook) handleRelease(body []byte, repo *git.Repo) error { + var release ghRelease + + err := json.Unmarshal(body, &release) + if err != nil { + return err + } + + if release.Release.TagName == "" { + return errors.New("The release request contained an invalid TagName.") + } + + logger().Printf("Received new release '%s'. -> Updating local repository to this release.", release.Release.Name) + + // Update the local branch to the release tag name + // this will pull the release tag. + repo.Branch = release.Release.TagName + repo.Pull() + + return nil +} diff --git a/middleware/git/webhook/github_hook_test.go b/middleware/git/webhook/github_hook_test.go new file mode 100644 index 00000000..b189164e --- /dev/null +++ b/middleware/git/webhook/github_hook_test.go @@ -0,0 +1,63 @@ +package webhook + +import ( + "bytes" + "github.com/mholt/caddy/middleware/git" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGithubDeployPush(t *testing.T) { + repo := &git.Repo{Branch: "master", HookUrl: "/github_deploy", HookSecret: "supersecret"} + ghHook := GithubHook{} + + for i, test := range []struct { + body string + event string + responseBody string + code int + }{ + {"", "", "", 400}, + {"", "push", "", 400}, + {pushBodyOther, "push", "", 200}, + {pushBodyPartial, "push", "", 400}, + {"", "release", "", 400}, + {"", "ping", "pong", 200}, + } { + + req, err := http.NewRequest("POST", "/github_deploy", bytes.NewBuffer([]byte(test.body))) + if err != nil { + t.Fatalf("Test %v: Could not create HTTP request: %v", i, err) + } + + if test.event != "" { + req.Header.Add("X-Github-Event", test.event) + } + + rec := httptest.NewRecorder() + + code, err := ghHook.Handle(rec, req, repo) + + if code != test.code { + t.Errorf("Test %d: Expected response code to be %d but was %d", i, test.code, code) + } + + if rec.Body.String() != test.responseBody { + t.Errorf("Test %d: Expected response body to be '%v' but was '%v'", i, test.responseBody, rec.Body.String()) + } + } + +} + +var pushBodyPartial = ` +{ + "ref": "" +} +` + +var pushBodyOther = ` +{ + "ref": "refs/heads/some-other-branch" +} +` diff --git a/middleware/git/webhook/webhook.go b/middleware/git/webhook/webhook.go new file mode 100644 index 00000000..59dc2d6c --- /dev/null +++ b/middleware/git/webhook/webhook.go @@ -0,0 +1,43 @@ +package webhook + +import ( + "github.com/mholt/caddy/middleware" + "github.com/mholt/caddy/middleware/git" + "net/http" +) + +// Middleware for handling web hooks of git providers +type WebHook struct { + Repo *git.Repo + Next middleware.Handler +} + +// Interface for specific providers to implement. +type hookHandler interface { + DoesHandle(http.Header) bool + Handle(w http.ResponseWriter, r *http.Request, repo *git.Repo) (int, error) +} + +// Slice of all registered hookHandlers. +// Register new hook handlers here! +var handlers = []hookHandler{ + GithubHook{}, +} + +// ServeHTTP implements the middlware.Handler interface. +func (h WebHook) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) { + + if r.URL.Path == h.Repo.HookUrl { + + for _, handler := range handlers { + // if a handler indicates it does handle the request, + // we do not try other handlers. Only one handler ever + // handles a specific request. + if handler.DoesHandle(r.Header) { + return handler.Handle(w, r, h.Repo) + } + } + } + + return h.Next.ServeHTTP(w, r) +}