├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── github-webhook.js ├── package.json └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - 8 5 | - 10 6 | - 12 7 | - lts/* 8 | - current 9 | branches: 10 | only: 11 | - master 12 | notifications: 13 | email: 14 | - rod@vagg.org 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright (c) 2015 Rod Vagg 5 | --------------------------- 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # github-webhook 2 | 3 | [![Build Status](https://travis-ci.com/rvagg/github-webhook.svg?branch=master)](https://travis-ci.com/rvagg/github-webhook) 4 | 5 | [![NPM](https://nodei.co/npm/github-webhook.svg)](https://nodei.co/npm/github-webhook/) 6 | 7 | A stand-alone GitHub Webhook end-point server. 8 | 9 | ## Example 10 | 11 | ```text 12 | github-webhook \ 13 | --port=9999 \ 14 | --path=/webhook \ 15 | --secret=mygithubsecret \ 16 | --log=/var/log/webhook.log \ 17 | --rule='push:ref == refs/heads/master && repository.name == myrepo:echo "yay!"' 18 | ``` 19 | 20 | You can also specify a `--config ` where *file* is a JSON file containing the same properties as are available as commandline options. The commandline will always override properties in the config file though. 21 | 22 | ```json 23 | { 24 | "port": 9999, 25 | "path": "/webhook", 26 | "secret": "mygithubsecret", 27 | "log": "/var/log/webhook.log", 28 | "rules": [{ 29 | "event": "push", 30 | "match": "ref == \"refs/heads/master\" && repository.name == \"myrepo\"", 31 | "exec": "echo yay!" 32 | }] 33 | } 34 | ``` 35 | 36 | ## Options 37 | 38 | * **port** (required): the port for the server to listen to (also respects `PORT` env var), should match what you tell GitHub 39 | * **path** (required): the path / route to listen to webhook requests on, should match what you tell GitHub 40 | * **secret** (required): the key used to hash the payload by GitHub that we verify against, should match what you tell GitHub 41 | * **host** (optional): if you want to restrict `listen()` to a specific host 42 | * **log** (optional): a file to print logs to, each command execution will be logged, also note that you can set the `DEBUG` env var to see debug output (see [debug](https://github.com/visionmedia/debug)). Note that the special strings 'stdout' and 'stderr' will redirect log output to standard out and standard error respectively rather than files with those names. 43 | * **rules** (optional): an array of objects representing rules to match against and commands to execute, can also be supplied as individual `--rule` commandline arguments where the 3 properties are separated by `:` (details below) 44 | 45 | ### Rules 46 | 47 | When reacting to valid GitHub Webhook payloads, you can specify any number of rules that will be matched and execute commands in a forked shell. Rules have three components: 48 | 49 | * `"event"`: the event type to match, see the [GitHub Webhooks documentation](https://developer.github.com/webhooks/) for more details on the events you can receive 50 | * `"match"`: a basic object matching rule that will be applied against the payload received from GitHub. Should be flexible enough to match very specific parts of the PayLoad. See [matchme](https://github.com/DamonOehlman/matchme) for how this works. 51 | * `"exec"`: a system command to execute if this rule is matched, should obviously be something related to the event, perhaps a deploy on `"push"` events? **Note**: if you provide a string it will be run with `sh -c ""` (unlikely to be Windows-friendly), however if you provide an array of strings then the first element will be executed with the remaining elements as its arguments. 52 | 53 | You can either specify these rules in an array on the `"rules"` property in the config file, or as separate `--rule` commandline arguments where the components are separated by `:`, e.g.: `--rule event:match:exec` (you will generally want to quote the rule to prevent shell trickery). 54 | 55 | ## Programatic usage 56 | 57 | You can `var server = require('github-webhook')(options)` and you'll receive a `http.Server` object that has been prepared but not started. 58 | 59 | ## More information 60 | 61 | **github-webhook** is powered by [github-webhook-handler](https://github.com/rvagg/github-webhook-handler), see that for more details. 62 | 63 | ## License 64 | 65 | **github-webhook** is Copyright (c) 2015 Rod Vagg and licensed under the MIT License. All rights not explicitly granted in the MIT License are reserved. See the included [LICENSE.md](./LICENSE.md) file for more details. 66 | -------------------------------------------------------------------------------- /github-webhook.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const http = require('http') 4 | const fs = require('fs') 5 | const spawn = require('child_process').spawn 6 | const createHandler = require('github-webhook-handler') 7 | const debug = require('debug') 8 | const matchme = require('matchme') 9 | const split2 = require('split2') 10 | const through2 = require('through2') 11 | const argv = require('minimist')(process.argv.slice(2)) 12 | const serverDebug = debug('github-webhook:server') 13 | const eventsDebug = debug('github-webhook:events') 14 | 15 | if (require.main === module) { 16 | let config = {} 17 | 18 | if (typeof argv.config === 'string') { 19 | config = JSON.parse(fs.readFileSync(argv.config)) 20 | } 21 | 22 | if (argv.port !== undefined) { 23 | config.port = argv.port 24 | } else if (process.env.PORT !== undefined) { 25 | config.port = process.env.PORT 26 | } 27 | 28 | if (argv.host !== undefined) { 29 | config.host = String(argv.host) 30 | } 31 | 32 | if (argv.secret !== undefined) { 33 | config.secret = String(argv.secret) 34 | } 35 | 36 | if (argv.path !== undefined) { 37 | config.path = String(argv.path) 38 | } 39 | 40 | if (argv.log !== undefined) { 41 | config.log = String(argv.log) 42 | } 43 | 44 | if (!Array.isArray(config.rules)) { 45 | config.rules = [] 46 | } 47 | 48 | if (argv.rule) { 49 | config.rules = config.rules.concat( 50 | collectRules(Array.isArray(argv.rule) ? argv.rule : [argv.rule]) 51 | ) 52 | } 53 | 54 | const listening = function listening (err) { 55 | if (err) { 56 | throw err 57 | } 58 | 59 | serverDebug(`Listening on http://${this.address().address}:${this.address().port}`) 60 | } 61 | 62 | const server = createServer(config) 63 | 64 | server.listen.apply(server, config.host 65 | ? [config.port, config.host, listening] 66 | : [config.port, listening] 67 | ) 68 | } 69 | 70 | function collectRules (rules) { 71 | return rules.map((rule) => { 72 | let c = rule.indexOf(':') 73 | if (c < 0) { 74 | return 75 | } 76 | 77 | const event = rule.substring(0, c) 78 | 79 | rule = rule.substring(c + 1) 80 | c = rule.indexOf(':') 81 | if (c < 0) { 82 | return 83 | } 84 | 85 | const match = rule.substring(0, c) 86 | const exec = rule.substring(c + 1) 87 | 88 | return event && match && exec && { 89 | event: event, 90 | match: match, 91 | exec: exec 92 | } 93 | }).filter(Boolean) 94 | } 95 | 96 | function createServer (options) { 97 | if (options.port === undefined) { 98 | throw new TypeError('must provide a \'port\' option') 99 | } 100 | 101 | if (!Array.isArray(options.rules)) { 102 | options.rules = [] 103 | } 104 | 105 | const server = http.createServer() 106 | const handler = createHandler(options) 107 | const logStream = typeof options.log === 'string' && ( 108 | options.log === 'stdout' 109 | ? process.stdout 110 | : options.log === 'stderr' 111 | ? process.stderr 112 | : fs.createWriteStream(options.log) 113 | ) 114 | 115 | server.webhookHandler = handler 116 | 117 | server.on('request', (req, res) => { 118 | serverDebug(`Connection from ${req.socket.address().address}:${req.socket.address().port}`) 119 | 120 | handler(req, res, (err) => { 121 | function response (code, msg) { 122 | const address = req.socket.address() 123 | 124 | serverDebug('Response to %s:%s: %d "%s"' 125 | , address ? address.address : 'unknown' 126 | , address ? address.port : '??' 127 | , code 128 | , msg 129 | ) 130 | 131 | res.writeHead(code, { 'content-type': 'text/json' }) 132 | res.end(JSON.stringify({ error: msg })) 133 | } 134 | 135 | if (err) { 136 | return response(500, `Internal server error: ${err.message}`) 137 | } 138 | 139 | response(404, 'Resource not found on this server') 140 | }) 141 | }) 142 | 143 | handler.on('error', (err) => { 144 | eventsDebug('Non-fatal error: ' + JSON.stringify(err.message)) 145 | }) 146 | 147 | handler.on('*', (event) => { 148 | eventsDebug(JSON.stringify(event)) 149 | handleRules(logStream, options.rules, event) 150 | }) 151 | 152 | return server 153 | } 154 | 155 | function prefixStream (stream, prefix) { 156 | return stream.pipe(split2()).pipe(through2((data, enc, callback) => { 157 | callback(null, `${prefix}${data}\n`) 158 | })) 159 | } 160 | 161 | function envFromPayload (payload, prefix, env) { 162 | if (!env) { 163 | env = {} 164 | } 165 | 166 | if (payload.ref && payload.ref.startsWith('refs/heads/')) { 167 | payload.branch = payload.ref.substring('refs/heads/'.length) 168 | } else { 169 | payload.branch = null 170 | } 171 | 172 | Object.keys(payload).forEach((k) => { 173 | const val = payload[k] 174 | switch (typeof val) { 175 | case 'boolean': 176 | case 'number': 177 | case 'string': 178 | env[prefix + k] = val 179 | break 180 | case 'object': 181 | if (val) { 182 | envFromPayload(val, prefix + k + '_', env) 183 | } 184 | break 185 | } 186 | }) 187 | 188 | return env 189 | } 190 | 191 | function handleRules (logStream, rules, event) { 192 | function executeRule (rule) { 193 | if (rule.executing === true) { 194 | rule.queued = true // we're busy working on this rule, queue up another run 195 | return 196 | } 197 | 198 | rule.executing = true 199 | 200 | const startTs = Date.now() 201 | const eventStr = `event="${rule.event}", match="${rule.match}", exec="${rule.exec}"` 202 | const exec = Array.isArray(rule.exec) ? rule.exec : ['sh', '-c', rule.exec] 203 | 204 | eventsDebug('Matched rule for %s', eventStr) 205 | 206 | const cp = spawn(exec.shift(), exec, { 207 | env: Object.assign(envFromPayload(event.payload, 'gh_'), process.env) 208 | }) 209 | 210 | cp.on('error', (err) => { 211 | return eventsDebug('Error executing command [%s]: %s', rule.exec, err.message) 212 | }) 213 | 214 | cp.on('close', (code) => { 215 | eventsDebug('Executed command [%s] exited with [%d]', rule.exec, code) 216 | 217 | if (logStream) { 218 | logStream.write(eventStr + '\n') 219 | logStream.write(new Date() + '\n') 220 | logStream.write('Took ' + (Date.now() - startTs) + ' ms\n') 221 | } 222 | 223 | rule.executing = false 224 | if (rule.queued === true) { 225 | rule.queued = false 226 | executeRule(rule) // do it again! 227 | } 228 | }) 229 | 230 | if (logStream) { 231 | prefixStream(cp.stdout, 'stdout: ').pipe(logStream, { end: false }) 232 | prefixStream(cp.stderr, 'stderr: ').pipe(logStream, { end: false }) 233 | } 234 | } 235 | 236 | rules.forEach((rule) => { 237 | if (rule.event !== '*' && rule.event !== event.event) { 238 | return 239 | } 240 | 241 | if (!matchme(event.payload, rule.match)) { 242 | return 243 | } 244 | 245 | executeRule(rule) 246 | }) 247 | } 248 | 249 | module.exports = createServer 250 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-webhook", 3 | "version": "2.0.2", 4 | "description": "A flexible web server for reacting GitHub Webhooks", 5 | "main": "github-webhook.js", 6 | "scripts": { 7 | "lint": "standard *.js", 8 | "test": "npm run lint && node test.js" 9 | }, 10 | "bin": { 11 | "github-webhook": "./github-webhook.js" 12 | }, 13 | "keywords": [ 14 | "github", 15 | "webhooks" 16 | ], 17 | "author": "Rod Vagg (http://r.va.gg)", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/rvagg/github-webhook.git" 21 | }, 22 | "license": "MIT", 23 | "devDependencies": { 24 | "bl": "^4.0.1", 25 | "standard": "^14.3.3", 26 | "supertest": "^4.0.2", 27 | "tape": "^4.13.2" 28 | }, 29 | "dependencies": { 30 | "debug": "^4.1.1", 31 | "github-webhook-handler": "^1.0.0", 32 | "matchme": "^2.0.1", 33 | "minimist": "^1.2.5", 34 | "split2": "^3.1.1", 35 | "through2": "^3.0.1" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const crypto = require('crypto') 4 | const test = require('tape') 5 | const supertest = require('supertest') 6 | const webhook = require('./') 7 | 8 | function signBlob (key, blob) { 9 | return `sha1=${crypto.createHmac('sha1', key).update(blob).digest('hex')}` 10 | } 11 | 12 | test('invalid url gets 404', (t) => { 13 | t.plan(1) 14 | 15 | const options = { port: 0, path: '/webhook', secret: 'foofaa' } 16 | const server = webhook(options) 17 | 18 | supertest(server) 19 | .post('/') 20 | .set('X-Hub-Signature', signBlob('foofaa', '{}')) 21 | .set('X-Github-Event', 'issues') 22 | .set('X-Github-Delivery', '123abc') 23 | .expect('Content-Type', /json/) 24 | .expect(404) 25 | .send('{}') 26 | .end((err) => { 27 | t.error(err) 28 | }) 29 | }) 30 | 31 | test('valid url, incomplete data gets 400', (t) => { 32 | t.plan(1) 33 | 34 | const options = { port: 0, path: '/webhook', secret: 'foofaa' } 35 | const server = webhook(options) 36 | 37 | supertest(server) 38 | .post('/webhook') 39 | .set('X-Github-Event', 'issues') 40 | .set('X-Github-Delivery', '123abc') 41 | .expect('Content-Type', /json/) 42 | .expect(400) 43 | .send('{}') 44 | .end((err) => { 45 | t.error(err) 46 | }) 47 | }) 48 | 49 | test('valid url, complete data gets 200', (t) => { 50 | t.plan(2) 51 | 52 | const options = { port: 0, path: '/webhook', secret: 'foofaa' } 53 | const server = webhook(options) 54 | const obj = { some: 'github', object: 'with', properties: true } 55 | const json = JSON.stringify(obj) 56 | const id = '123abc' 57 | const eventType = 'issues' 58 | 59 | server.webhookHandler.on(eventType, (event) => { 60 | delete event.host // too hard 61 | t.deepEqual(event, { event: eventType, id: id, payload: obj, protocol: undefined, url: '/webhook', path: '/webhook' }) 62 | }) 63 | 64 | supertest(server) 65 | .post('/webhook') 66 | .set('X-Hub-Signature', signBlob('foofaa', json)) 67 | .set('X-Github-Event', eventType) 68 | .set('X-Github-Delivery', id) 69 | .send(json) 70 | .expect('Content-Type', /json/) 71 | .expect(200) 72 | .end((err) => { 73 | t.error(err) 74 | }) 75 | }) 76 | 77 | test('valid request triggers rule', (t) => { 78 | t.plan(5) 79 | 80 | const tmpfile = path.join(__dirname, '/__test_data.' + Math.random()) 81 | const eventType = 'issues' 82 | const options = { 83 | port: 0, 84 | path: '/webhook', 85 | secret: 'foofaa', 86 | rules: [ 87 | { // should not trigger this event 88 | event: eventType, 89 | match: 'some == xxgithub', 90 | exec: ['sh', '-c', `echo "w00t!" > ${tmpfile}2`] 91 | }, 92 | { // should trigger this event 93 | event: eventType, 94 | match: 'some == github', 95 | exec: `echo "w00t!" > ${tmpfile}` 96 | } 97 | ] 98 | } 99 | const server = webhook(options) 100 | const obj = { some: 'github', object: 'with', properties: true } 101 | const json = JSON.stringify(obj) 102 | const id = '123abc' 103 | 104 | t.on('end', () => { 105 | fs.unlink(tmpfile, () => {}) 106 | }) 107 | 108 | server.webhookHandler.on(eventType, (event) => { 109 | delete event.host // too hard 110 | t.deepEqual(event, { event: eventType, id: id, payload: obj, protocol: undefined, url: '/webhook', path: '/webhook' }) 111 | setTimeout(() => { 112 | fs.readFile(tmpfile, 'utf8', (err, data) => { 113 | t.error(err) 114 | t.equal(data, 'w00t!\n') 115 | }) 116 | fs.stat(`${tmpfile}2`, (err) => { 117 | t.ok(err, 'does not exist, didn\'t trigger second event') 118 | }) 119 | }, 100) 120 | }) 121 | 122 | supertest(server) 123 | .post('/webhook') 124 | .set('X-Hub-Signature', signBlob('foofaa', json)) 125 | .set('X-Github-Event', eventType) 126 | .set('X-Github-Delivery', id) 127 | .send(json) 128 | .expect('Content-Type', /json/) 129 | .expect(200) 130 | .end((err) => { 131 | t.error(err) 132 | }) 133 | }) 134 | --------------------------------------------------------------------------------