mirror of
https://github.com/caddyserver/caddy.git
synced 2025-01-05 18:44:58 +03:00
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
This commit is contained in:
parent
4852f0580b
commit
b4780a41d3
5 changed files with 303 additions and 6 deletions
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
|
|
159
middleware/git/webhook/github_hook.go
Normal file
159
middleware/git/webhook/github_hook.go
Normal file
|
@ -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/<id>"
|
||||
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
|
||||
}
|
63
middleware/git/webhook/github_hook_test.go
Normal file
63
middleware/git/webhook/github_hook_test.go
Normal file
|
@ -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"
|
||||
}
|
||||
`
|
43
middleware/git/webhook/webhook.go
Normal file
43
middleware/git/webhook/webhook.go
Normal file
|
@ -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)
|
||||
}
|
Loading…
Reference in a new issue