├── LICENSE ├── README.rst ├── __init__.py ├── index.py ├── repos.json.example └── requirements.txt /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2014 Silviu Tantos 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | NOTE 2 | #### 3 | 4 | I do not maintain this project and it should only be used as an example. 5 | 6 | The following might be a better implrementation: 7 | 8 | - https://github.com/carlos-jenkins/python-github-webhooks 9 | - https://github.com/bloomberg/python-github-webhook 10 | 11 | Flask webhook for Github 12 | ######################## 13 | A very simple github post-receive web hook handler that executes per default a 14 | pull uppon receiving. The executed action is configurable per repository. 15 | 16 | It will also verify that the POST request originated from github.com or a 17 | defined GitHub Enterprise server. Additionally will ensure that it has a valid 18 | signature (only when the ``key`` setting is properly configured). 19 | 20 | Gettings started 21 | ---------------- 22 | 23 | Installation Requirements 24 | ========================= 25 | 26 | Install dependencies found in ``requirements.txt``. 27 | 28 | .. code-block:: console 29 | 30 | pip install -r requirements.txt 31 | 32 | Repository Configuration 33 | ======================== 34 | 35 | Edit ``repos.json`` to configure repositories, each repository must be 36 | registered under the form ``GITHUB_USER/REPOSITORY_NAME``. 37 | 38 | .. code-block:: json 39 | 40 | { 41 | "razius/puppet": { 42 | "path": "/home/puppet", 43 | "key": "MyVerySecretKey", 44 | "action": [["git", "pull", "origin", "master"]] 45 | }, 46 | "d3non/somerandomexample/branch:live": { 47 | "path": "/home/exampleapp", 48 | "key": "MyVerySecretKey", 49 | "action": [["git", "pull", "origin", "live"], 50 | ["echo", "execute", "some", "commands", "..."]] 51 | } 52 | } 53 | 54 | Runtime Configuration 55 | ===================== 56 | 57 | Runtime operation is influenced by a set of environment variables which require 58 | being set to influence operation. Only REPOS_JSON_PATH is required to be set, 59 | as this is required to know how to act on actions from repositories. The 60 | remaining variables are optional. USE_PROXYFIX needs to be set to true if 61 | being used behind a WSGI proxy, and is not required otherwise. GHE_ADDRESS 62 | needs to be set to the IP address of a GitHub Enterprise instance if that is 63 | the source of webhooks. 64 | 65 | Set environment variable for the ``repos.json`` config. 66 | 67 | .. code-block:: console 68 | 69 | export REPOS_JSON_PATH=/path/to/repos.json 70 | 71 | Start the server. 72 | 73 | .. code-block:: console 74 | 75 | python index.py 80 76 | 77 | Start the server with root privileges, if required, while preserving existing environment variables. 78 | 79 | .. code-block:: console 80 | 81 | sudo -E python index.py 80 82 | 83 | Start the server behind a proxy (see: 84 | http://flask.pocoo.org/docs/deploying/wsgi-standalone/#proxy-setups) 85 | 86 | .. code-block:: console 87 | 88 | USE_PROXYFIX=true python index.py 8080 89 | 90 | Start the server to be used with a GitHub Enterprise instance. 91 | 92 | .. code-block:: console 93 | 94 | GHE_ADDRESS=192.0.2.50 python index.py 80 95 | 96 | 97 | Go to your repository's settings on `github.com `_ or your 98 | GitHub Enterprise instance and register your public URL under 99 | ``Webhooks & services -> Webhooks``. 100 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/razius/github-webhook-handler/5e406fe7bbb70a41bd3d4eba638de15c5441816d/__init__.py -------------------------------------------------------------------------------- /index.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import io 3 | import os 4 | import re 5 | import sys 6 | import json 7 | import subprocess 8 | import requests 9 | import ipaddress 10 | import hmac 11 | from hashlib import sha1 12 | from flask import Flask, request, abort 13 | 14 | """ 15 | Conditionally import ProxyFix from werkzeug if the USE_PROXYFIX environment 16 | variable is set to true. If you intend to import this as a module in your own 17 | code, use os.environ to set the environment variable before importing this as a 18 | module. 19 | 20 | .. code:: python 21 | 22 | os.environ['USE_PROXYFIX'] = 'true' 23 | import flask-github-webhook-handler.index as handler 24 | 25 | """ 26 | if os.environ.get('USE_PROXYFIX', None) == 'true': 27 | from werkzeug.contrib.fixers import ProxyFix 28 | 29 | app = Flask(__name__) 30 | app.debug = os.environ.get('DEBUG') == 'true' 31 | 32 | # The repos.json file should be readable by the user running the Flask app, 33 | # and the absolute path should be given by this environment variable. 34 | REPOS_JSON_PATH = os.environ['REPOS_JSON_PATH'] 35 | 36 | 37 | @app.route("/", methods=['GET', 'POST']) 38 | def index(): 39 | if request.method == 'GET': 40 | return 'OK' 41 | elif request.method == 'POST': 42 | # Store the IP address of the requester 43 | request_ip = ipaddress.ip_address(u'{0}'.format(request.remote_addr)) 44 | 45 | # If VALIDATE_SOURCEIP is set to false, do not validate source IP 46 | if os.environ.get('VALIDATE_SOURCEIP', None) != 'false': 47 | 48 | # If GHE_ADDRESS is specified, use it as the hook_blocks. 49 | if os.environ.get('GHE_ADDRESS', None): 50 | hook_blocks = [unicode(os.environ.get('GHE_ADDRESS'))] 51 | # Otherwise get the hook address blocks from the API. 52 | else: 53 | hook_blocks = requests.get('https://api.github.com/meta').json()[ 54 | 'hooks'] 55 | 56 | # Check if the POST request is from github.com or GHE 57 | for block in hook_blocks: 58 | if ipaddress.ip_address(request_ip) in ipaddress.ip_network(block): 59 | break # the remote_addr is within the network range of github. 60 | else: 61 | if str(request_ip) != '127.0.0.1': 62 | abort(403) 63 | 64 | if request.headers.get('X-GitHub-Event') == "ping": 65 | return json.dumps({'msg': 'Hi!'}) 66 | if request.headers.get('X-GitHub-Event') != "push": 67 | return json.dumps({'msg': "wrong event type"}) 68 | 69 | repos = json.loads(io.open(REPOS_JSON_PATH, 'r').read()) 70 | 71 | payload = json.loads(request.data) 72 | repo_meta = { 73 | 'name': payload['repository']['name'], 74 | 'owner': payload['repository']['owner']['name'], 75 | } 76 | 77 | # Try to match on branch as configured in repos.json 78 | match = re.match(r"refs/heads/(?P.*)", payload['ref']) 79 | if match: 80 | repo_meta['branch'] = match.groupdict()['branch'] 81 | repo = repos.get( 82 | '{owner}/{name}/branch:{branch}'.format(**repo_meta), None) 83 | 84 | # Fallback to plain owner/name lookup 85 | if not repo: 86 | repo = repos.get('{owner}/{name}'.format(**repo_meta), None) 87 | 88 | if repo and repo.get('path', None): 89 | # Check if POST request signature is valid 90 | key = repo.get('key', None) 91 | if key: 92 | signature = request.headers.get('X-Hub-Signature').split( 93 | '=')[1] 94 | if type(key) == unicode: 95 | key = key.encode() 96 | mac = hmac.new(key, msg=request.data, digestmod=sha1) 97 | if not compare_digest(mac.hexdigest(), signature): 98 | abort(403) 99 | 100 | if repo.get('action', None): 101 | for action in repo['action']: 102 | subp = subprocess.Popen(action, cwd=repo.get('path', '.')) 103 | subp.wait() 104 | return 'OK' 105 | 106 | # Check if python version is less than 2.7.7 107 | if sys.version_info < (2, 7, 7): 108 | # http://blog.turret.io/hmac-in-go-python-ruby-php-and-nodejs/ 109 | def compare_digest(a, b): 110 | """ 111 | ** From Django source ** 112 | 113 | Run a constant time comparison against two strings 114 | 115 | Returns true if a and b are equal. 116 | 117 | a and b must both be the same length, or False is 118 | returned immediately 119 | """ 120 | if len(a) != len(b): 121 | return False 122 | 123 | result = 0 124 | for ch_a, ch_b in zip(a, b): 125 | result |= ord(ch_a) ^ ord(ch_b) 126 | return result == 0 127 | else: 128 | compare_digest = hmac.compare_digest 129 | 130 | if __name__ == "__main__": 131 | try: 132 | port_number = int(sys.argv[1]) 133 | except: 134 | port_number = 80 135 | if os.environ.get('USE_PROXYFIX', None) == 'true': 136 | app.wsgi_app = ProxyFix(app.wsgi_app) 137 | app.run(host='0.0.0.0', port=port_number) 138 | -------------------------------------------------------------------------------- /repos.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "razius/puppet": { 3 | "path": "/home/puppet" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==0.10.1 2 | ipaddress==1.0.6 3 | requests==2.2.1 4 | --------------------------------------------------------------------------------