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) +}