├── LICENSE.md ├── README.md ├── doc.go ├── hookserve ├── doc.go └── hookserve.go └── util └── hookserve └── main.go /LICENSE.md: -------------------------------------------------------------------------------- 1 | Open Source License (BSD 3-Clause) 2 | ---------------------------------- 3 | 4 | Copyright (c) 2014, Patrick Hayes / HighWire Press 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | HookServe 2 | ========= 3 | 4 | http://godoc.org/github.com/phayes/hookserve/hookserve 5 | 6 | HookServe is a small golang utility for receiving github webhooks. It's easy to use, flexible, and provides strong security though GitHub's HMAC webhook verification scheme. 7 | 8 | ```go 9 | server := hookserve.NewServer() 10 | server.Port = 8888 11 | server.Secret = "supersecretcode" 12 | server.GoListenAndServe() 13 | 14 | // Everytime the server receives a webhook event, print the results 15 | for event := range server.Events { 16 | fmt.Println(event.Owner + " " + event.Repo + " " + event.Branch + " " + event.Commit) 17 | } 18 | ``` 19 | 20 | 21 | ###Command Line Utility 22 | 23 | 24 | It also comes with a command-line utility that lets you pass webhook push events to other commands. 25 | 26 | ```sh 27 | $ hookserve --port=8888 logger -t PushEvent #log github webhook push event to the system log (/var/log/message) via the logger command 28 | ``` 29 | 30 | #####Command Line Utility Downloads 31 | - Linux: https://phayes.github.io/bin/current/hookserve/linux/hookserve.gz 32 | - Mac: https://phayes.github.io/bin/current/hookserve/mac/hookserve.gz 33 | 34 | #####Building Command Line Utility From Source 35 | ```bash 36 | sudo apt-get install golang # Download go. Alternativly build from source: https://golang.org/doc/install/source 37 | mkdir ~/.gopath && export GOPATH=~/.gopath # Replace with desired GOPATH 38 | export PATH=$PATH:$GOPATH/bin # For convenience, add go's bin dir to your PATH 39 | go get github.com/phayes/hookserve/cmd/hookserve 40 | ``` 41 | 42 | ###Settings up GitHub Webhooks 43 | 44 | 45 | Setting up webhooks on github is easy. Navigate to `github.com///settings/hooks` and create a new webhook. Setting up your webhook should look something like this: 46 | 47 | ![Configuring webhooks in github](https://i.imgur.com/u3ciUD7.png) 48 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | HookServe is a small golang utility for receiving github webhooks. It's easy to use, flexible, and provides strong security though GitHub's HMAC webhook verification scheme. 3 | server := hookserve.NewServer() 4 | server.Port = 8888 5 | server.Secret = "supersecretcode" 6 | server.GoListenAndServe() 7 | 8 | for { 9 | select { 10 | case event := <-server.Events: 11 | fmt.Println(event.Owner + " " + event.Repo + " " + event.Branch + " " + event.Commit) 12 | default: 13 | time.Sleep(100) 14 | } 15 | } 16 | 17 | 18 | Command Line Utility 19 | 20 | It also comes with a command-line utility that lets you pass webhook push events to other commands. 21 | $ hookserve --port=8888 logger -t PushEvent #log github webhook push event to the system log (/var/log/message) via the logger command 22 | 23 | 24 | Settings up GitHub Webhooks 25 | 26 | Setting up webhooks on github is easy. Navigate to `github.com///settings/hooks` and create a new webhook. 27 | */ 28 | package hookserve 29 | -------------------------------------------------------------------------------- /hookserve/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | HookServe is a small golang utility for receiving github webhooks. It's easy to use, flexible, and provides strong security though GitHub's HMAC webhook verification scheme. 3 | server := hookserve.NewServer() 4 | server.Port = 8888 5 | server.Secret = "supersecretcode" 6 | server.GoListenAndServe() 7 | 8 | for { 9 | select { 10 | case event := <-server.Events: 11 | fmt.Println(event.Owner + " " + event.Repo + " " + event.Branch + " " + event.Commit) 12 | default: 13 | time.Sleep(100) 14 | } 15 | } 16 | 17 | 18 | Command Line Utility 19 | 20 | It also comes with a command-line utility that lets you pass webhook push events to other commands 21 | $ hookserve --port=8888 logger -t PushEvent #log github webhook push event to the system log (/var/log/message) via the logger command 22 | 23 | 24 | Settings up GitHub Webhooks 25 | 26 | Setting up webhooks on github is easy. Navigate to `github.com///settings/hooks` and create a new webhook. 27 | */ 28 | package hookserve 29 | -------------------------------------------------------------------------------- /hookserve/hookserve.go: -------------------------------------------------------------------------------- 1 | package hookserve 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha1" 6 | "encoding/hex" 7 | "errors" 8 | "github.com/bmatsuo/go-jsontree" 9 | "io/ioutil" 10 | "net/http" 11 | "strconv" 12 | "strings" 13 | ) 14 | 15 | var ErrInvalidEventFormat = errors.New("Unable to parse event string. Invalid Format.") 16 | 17 | type Event struct { 18 | Owner string // The username of the owner of the repository 19 | Repo string // The name of the repository 20 | Branch string // The branch the event took place on 21 | Commit string // The head commit hash attached to the event 22 | Type string // Can be either "pull_request" or "push" 23 | Action string // For Pull Requests, contains "assigned", "unassigned", "labeled", "unlabeled", "opened", "closed", "reopened", or "synchronize". 24 | BaseOwner string // For Pull Requests, contains the base owner 25 | BaseRepo string // For Pull Requests, contains the base repo 26 | BaseBranch string // For Pull Requests, contains the base branch 27 | } 28 | 29 | // Create a new event from a string, the string format being the same as the one produced by event.String() 30 | func NewEvent(e string) (*Event, error) { 31 | // Trim whitespace 32 | e = strings.Trim(e, "\n\t ") 33 | 34 | // Split into lines 35 | parts := strings.Split(e, "\n") 36 | 37 | // Sanity checking 38 | if len(parts) != 5 || len(parts) != 8 { 39 | return nil, ErrInvalidEventFormat 40 | } 41 | for _, item := range parts { 42 | if len(item) < 8 { 43 | return nil, ErrInvalidEventFormat 44 | } 45 | } 46 | 47 | // Fill in values for the event 48 | event := Event{} 49 | event.Type = parts[0][8:] 50 | event.Owner = parts[1][8:] 51 | event.Repo = parts[2][8:] 52 | event.Branch = parts[3][8:] 53 | event.Commit = parts[4][8:] 54 | 55 | // Fill in extra values if it's a pull_request 56 | if event.Type == "pull_request" { 57 | if len(parts) == 9 { // New format 58 | event.Action = parts[5][8:] 59 | event.BaseOwner = parts[6][8:] 60 | event.BaseRepo = parts[7][8:] 61 | event.BaseBranch = parts[8][8:] 62 | } else if len(parts) == 8 { // Old Format 63 | event.BaseOwner = parts[5][8:] 64 | event.BaseRepo = parts[6][8:] 65 | event.BaseBranch = parts[7][8:] 66 | } else { 67 | return nil, ErrInvalidEventFormat 68 | } 69 | } 70 | 71 | return &event, nil 72 | } 73 | 74 | func (e *Event) String() (output string) { 75 | output += "type: " + e.Type + "\n" 76 | output += "owner: " + e.Owner + "\n" 77 | output += "repo: " + e.Repo + "\n" 78 | output += "branch: " + e.Branch + "\n" 79 | output += "commit: " + e.Commit + "\n" 80 | 81 | if e.Type == "pull_request" { 82 | output += "action: " + e.Action + "\n" 83 | output += "bowner: " + e.BaseOwner + "\n" 84 | output += "brepo: " + e.BaseRepo + "\n" 85 | output += "bbranch:" + e.BaseBranch + "\n" 86 | } 87 | 88 | return 89 | } 90 | 91 | type Server struct { 92 | Port int // Port to listen on. Defaults to 80 93 | Path string // Path to receive on. Defaults to "/postreceive" 94 | Secret string // Option secret key for authenticating via HMAC 95 | IgnoreTags bool // If set to false, also execute command if tag is pushed 96 | Events chan Event // Channel of events. Read from this channel to get push events as they happen. 97 | } 98 | 99 | // Create a new server with sensible defaults. 100 | // By default the Port is set to 80 and the Path is set to `/postreceive` 101 | func NewServer() *Server { 102 | return &Server{ 103 | Port: 80, 104 | Path: "/postreceive", 105 | IgnoreTags: true, 106 | Events: make(chan Event, 10), // buffered to 10 items 107 | } 108 | } 109 | 110 | // Spin up the server and listen for github webhook push events. The events will be passed to Server.Events channel. 111 | func (s *Server) ListenAndServe() error { 112 | return http.ListenAndServe(":"+strconv.Itoa(s.Port), s) 113 | } 114 | 115 | // Inside a go-routine, spin up the server and listen for github webhook push events. The events will be passed to Server.Events channel. 116 | func (s *Server) GoListenAndServe() { 117 | go func() { 118 | err := s.ListenAndServe() 119 | if err != nil { 120 | panic(err) 121 | } 122 | }() 123 | } 124 | 125 | // Checks if the given ref should be ignored 126 | func (s *Server) ignoreRef(rawRef string) bool { 127 | if rawRef[:10] == "refs/tags/" && !s.IgnoreTags { 128 | return false 129 | } 130 | return rawRef[:11] != "refs/heads/" 131 | } 132 | 133 | // Satisfies the http.Handler interface. 134 | // Instead of calling Server.ListenAndServe you can integrate hookserve.Server inside your own http server. 135 | // If you are using hookserve.Server in his way Server.Path should be set to match your mux pattern and Server.Port will be ignored. 136 | func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { 137 | defer req.Body.Close() 138 | 139 | if req.Method != "POST" { 140 | http.Error(w, "405 Method not allowed", http.StatusMethodNotAllowed) 141 | return 142 | } 143 | if req.URL.Path != s.Path { 144 | http.Error(w, "404 Not found", http.StatusNotFound) 145 | return 146 | } 147 | 148 | eventType := req.Header.Get("X-GitHub-Event") 149 | if eventType == "" { 150 | http.Error(w, "400 Bad Request - Missing X-GitHub-Event Header", http.StatusBadRequest) 151 | return 152 | } 153 | if eventType != "push" && eventType != "pull_request" { 154 | http.Error(w, "400 Bad Request - Unknown Event Type "+eventType, http.StatusBadRequest) 155 | return 156 | } 157 | 158 | body, err := ioutil.ReadAll(req.Body) 159 | if err != nil { 160 | http.Error(w, err.Error(), http.StatusInternalServerError) 161 | return 162 | } 163 | 164 | // If we have a Secret set, we should check the MAC 165 | if s.Secret != "" { 166 | sig := req.Header.Get("X-Hub-Signature") 167 | 168 | if sig == "" { 169 | http.Error(w, "403 Forbidden - Missing X-Hub-Signature required for HMAC verification", http.StatusForbidden) 170 | return 171 | } 172 | 173 | mac := hmac.New(sha1.New, []byte(s.Secret)) 174 | mac.Write(body) 175 | expectedMAC := mac.Sum(nil) 176 | expectedSig := "sha1=" + hex.EncodeToString(expectedMAC) 177 | if !hmac.Equal([]byte(expectedSig), []byte(sig)) { 178 | http.Error(w, "403 Forbidden - HMAC verification failed", http.StatusForbidden) 179 | return 180 | } 181 | } 182 | 183 | request := jsontree.New() 184 | err = request.UnmarshalJSON(body) 185 | if err != nil { 186 | http.Error(w, err.Error(), http.StatusInternalServerError) 187 | return 188 | } 189 | 190 | // Parse the request and build the Event 191 | event := Event{} 192 | 193 | if eventType == "push" { 194 | rawRef, err := request.Get("ref").String() 195 | if err != nil { 196 | http.Error(w, err.Error(), http.StatusInternalServerError) 197 | return 198 | } 199 | // If the ref is not a branch, we don't care about it 200 | if s.ignoreRef(rawRef) || request.Get("head_commit").IsNull() { 201 | return 202 | } 203 | 204 | // Fill in values 205 | event.Type = eventType 206 | event.Branch = rawRef[11:] 207 | event.Repo, err = request.Get("repository").Get("name").String() 208 | if err != nil { 209 | http.Error(w, err.Error(), http.StatusInternalServerError) 210 | return 211 | } 212 | event.Commit, err = request.Get("head_commit").Get("id").String() 213 | if err != nil { 214 | http.Error(w, err.Error(), http.StatusInternalServerError) 215 | return 216 | } 217 | event.Owner, err = request.Get("repository").Get("owner").Get("name").String() 218 | if err != nil { 219 | http.Error(w, err.Error(), http.StatusInternalServerError) 220 | return 221 | } 222 | } else if eventType == "pull_request" { 223 | event.Action, err = request.Get("action").String() 224 | if err != nil { 225 | http.Error(w, err.Error(), http.StatusInternalServerError) 226 | return 227 | } 228 | 229 | // Fill in values 230 | event.Type = eventType 231 | event.Owner, err = request.Get("pull_request").Get("head").Get("repo").Get("owner").Get("login").String() 232 | if err != nil { 233 | http.Error(w, err.Error(), http.StatusInternalServerError) 234 | return 235 | } 236 | event.Repo, err = request.Get("pull_request").Get("head").Get("repo").Get("name").String() 237 | if err != nil { 238 | http.Error(w, err.Error(), http.StatusInternalServerError) 239 | return 240 | } 241 | event.Branch, err = request.Get("pull_request").Get("head").Get("ref").String() 242 | if err != nil { 243 | http.Error(w, err.Error(), http.StatusInternalServerError) 244 | return 245 | } 246 | event.Commit, err = request.Get("pull_request").Get("head").Get("sha").String() 247 | if err != nil { 248 | http.Error(w, err.Error(), http.StatusInternalServerError) 249 | return 250 | } 251 | event.BaseOwner, err = request.Get("pull_request").Get("base").Get("repo").Get("owner").Get("login").String() 252 | if err != nil { 253 | http.Error(w, err.Error(), http.StatusInternalServerError) 254 | return 255 | } 256 | event.BaseRepo, err = request.Get("pull_request").Get("base").Get("repo").Get("name").String() 257 | if err != nil { 258 | http.Error(w, err.Error(), http.StatusInternalServerError) 259 | return 260 | } 261 | event.BaseBranch, err = request.Get("pull_request").Get("base").Get("ref").String() 262 | if err != nil { 263 | http.Error(w, err.Error(), http.StatusInternalServerError) 264 | return 265 | } 266 | } else { 267 | http.Error(w, "Unknown Event Type "+eventType, http.StatusInternalServerError) 268 | return 269 | } 270 | 271 | // We've built our Event - put it into the channel and we're done 272 | go func() { 273 | s.Events <- event 274 | }() 275 | 276 | w.Write([]byte(event.String())) 277 | } 278 | -------------------------------------------------------------------------------- /util/hookserve/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/codegangsta/cli" 6 | "github.com/phayes/hookserve/hookserve" 7 | "os" 8 | "os/exec" 9 | ) 10 | 11 | func main() { 12 | 13 | app := cli.NewApp() 14 | app.Name = "hookserve" 15 | app.Usage = "A small little application that listens for commit / push webhook events from github and runs a specified command\n\n" 16 | app.Usage += "EXAMPLE:\n" 17 | app.Usage += " hookserve --secret=whiskey --port=8888 echo #Echo back the information provided\n" 18 | app.Usage += " hookserve logger -t PushEvent #log the push event to the system log (/var/log/message)" 19 | app.Version = "1.0" 20 | app.Author = "Patrick Hayes" 21 | app.Email = "patrick.d.hayes@gmail.com" 22 | 23 | app.Flags = []cli.Flag{ 24 | cli.IntFlag{ 25 | Name: "port, p", 26 | Value: 80, 27 | Usage: "port on which to listen for github webhooks", 28 | }, 29 | cli.StringFlag{ 30 | Name: "secret, s", 31 | Value: "", 32 | Usage: "Secret for HMAC verification. If not provided no HMAC verification will be done and all valid requests will be processed", 33 | }, 34 | cli.BoolFlag{ 35 | Name: "tags, t", 36 | Usage: "Also execute the command when a tag is pushed", 37 | }, 38 | } 39 | 40 | app.Action = func(c *cli.Context) { 41 | server := hookserve.NewServer() 42 | server.Port = c.Int("port") 43 | server.Secret = c.String("secret") 44 | server.IgnoreTags = !c.Bool("tags") 45 | server.GoListenAndServe() 46 | 47 | for commit := range server.Events { 48 | if args := c.Args(); len(args) != 0 { 49 | root := args[0] 50 | rest := append(args[1:], commit.Owner, commit.Repo, commit.Branch, commit.Commit) 51 | cmd := exec.Command(root, rest...) 52 | cmd.Stdout = os.Stdout 53 | cmd.Stderr = os.Stderr 54 | cmd.Run() 55 | } else { 56 | fmt.Println(commit.Owner + " " + commit.Repo + " " + commit.Branch + " " + commit.Commit) 57 | } 58 | } 59 | } 60 | 61 | app.Run(os.Args) 62 | } 63 | --------------------------------------------------------------------------------