├── requirements.txt ├── test ├── hooks │ └── print_branch.sh └── archer │ ├── Dockerfile │ └── archer.py ├── .travis.yml ├── Makefile ├── Dockerfile ├── README.md └── webhook_listener.py /requirements.txt: -------------------------------------------------------------------------------- 1 | flask -------------------------------------------------------------------------------- /test/hooks/print_branch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Webhook received for branch '$1'" 4 | exit 0 5 | -------------------------------------------------------------------------------- /test/archer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | RUN pip3 install requests 4 | WORKDIR /app 5 | COPY archer.py /app/archer.py 6 | CMD ["python3", "archer.py"] 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: c 2 | services: 3 | - docker 4 | 5 | notifications: 6 | email: false 7 | 8 | before_install: 9 | - docker pull python:3 10 | - docker pull docker 11 | 12 | script: 13 | - make check 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Build the actual webhook listener 2 | build: 3 | docker build -t webhook . 4 | 5 | # Build a testing webhook-targeting one-shot wonder 6 | build-test: 7 | docker build -t webhook_archer test/archer 8 | 9 | # Run webhook listener, with example hook 10 | check: build build-test 11 | @-docker stop webhook >/dev/null 2>/dev/null 12 | docker run --rm --name=webhook -d -e WEBHOOK_SECRET=secret -e WEBHOOK_BRANCH_LIST=master,sf/testing -v $$(pwd)/test/hooks:/app/hooks:ro -ti webhook >/dev/null 13 | @sleep 1 14 | docker run --rm --link=webhook -ti -e WEBHOOK_SECRET=secret webhook_archer 15 | @docker stop webhook >/dev/null 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker:stable 2 | 3 | RUN apk add --no-cache python3 openssl-dev libffi-dev make git build-base python3-dev py3-pip bash && \ 4 | pip3 install docker-compose && \ 5 | apk del build-base python3-dev libffi-dev openssl-dev 6 | 7 | # Create /app/ and /app/hooks/ 8 | RUN mkdir -p /app/hooks/ 9 | 10 | WORKDIR /app 11 | 12 | # Install requirements 13 | COPY requirements.txt ./requirements.txt 14 | RUN pip3 install -r requirements.txt && \ 15 | rm -f requirements.txt 16 | 17 | # Copy in webhook listener script 18 | COPY webhook_listener.py ./webhook_listener.py 19 | CMD ["python3", "webhook_listener.py"] 20 | EXPOSE 8000 21 | -------------------------------------------------------------------------------- /test/archer/archer.py: -------------------------------------------------------------------------------- 1 | import requests, hmac, json, os, logging 2 | 3 | WEBHOOK_SECRET=os.getenv("WEBHOOK_SECRET", None) 4 | if WEBHOOK_SECRET is None: 5 | logging.error("Must define WEBHOOK_SECRET") 6 | exit(1) 7 | 8 | def send(headers={}, data=''): 9 | global WEBHOOK_SECRET 10 | 11 | # Sign the request with our secret 12 | mac = hmac.new(WEBHOOK_SECRET.encode('utf8'), msg=data.encode('utf8'), digestmod='sha1') 13 | headers['X-Hub-Signature'] = "sha1="+str(mac.hexdigest()) 14 | 15 | # Shoot off the webhook! 16 | r = requests.post('http://webhook:8000', headers=headers, data=data) 17 | try: 18 | return r.json() 19 | except: 20 | return r 21 | 22 | def send_ping(): 23 | return send(headers={'X-GitHub-Event': 'ping'}) 24 | 25 | def send_push(branch_name): 26 | return send( 27 | headers={'X-GitHub-Event': 'push'}, 28 | data=json.dumps({ 29 | 'ref': 'refs/heads/' + branch_name, 30 | }), 31 | ) 32 | 33 | # Test that a `ping` gets a `pong` 34 | r = send_ping() 35 | if r["msg"] != "pong": 36 | logging.error("Invalid response to `ping`: ", r) 37 | exit(1) 38 | else: 39 | print("ping good!") 40 | 41 | for good_branch in ("master", "sf/testing"): 42 | r = send_push(good_branch) 43 | if r["/app/hooks/print_branch.sh"]["stdout"] != "Webhook received for branch '%s'\n"%(good_branch): 44 | logging.error("Invalid response to `push` on branch %s"%(good_branch)) 45 | exit(1) 46 | else: 47 | print("push on %s good!"%(good_branch)) 48 | 49 | r = send_push("bad_branch_name") 50 | if r.status_code != 403: 51 | logging.error("Failed to fail on bad branch name push!") 52 | else: 53 | print("push on bad_branch_name good!") 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # docker-webhook 2 | 3 | Simple python application to listen for GitHub webhook events and run scripts in response to `push` events. This is mostly useful as a part of a larger project that needs to reload itself on deploy events. The behavior of this image can be altered through the use of environment variables, the full list of which are included in a table below. The suggested way of using this image is through a `docker-compose.yml` setup. To illustrate this, read through the following scenario and code bits: 4 | 5 | Let us imagine I have an application running with an nginx frontend and some kind of backend. I want this application to redeploy itself when it receives a `push` event from GitHub on either the branch `master` or `release-1.0`. To do so, I would first add configuration to the application's `docker-compose.yml` file similar to the following: 6 | 7 | ```yaml 8 | version: '2.1' 9 | services: 10 | frontend: 11 | // blah blah blah... 12 | main_app: 13 | // blah blah blah... 14 | webhook: 15 | restart: unless-stopped 16 | image: staticfloat/docker-webhook 17 | volumes: 18 | # Mount this code into /code 19 | - ./:/code 20 | # Mount the docker socket 21 | - /var/run/docker.sock:/var/run/docker.sock 22 | environment: 23 | - WEBHOOK_SECRET=${WEBHOOK_SECRET} 24 | - WEBHOOK_HOOKS_DIR=/code/hooks 25 | - WEBHOOK_BRANCH_LIST=master 26 | expose: 27 | - 8000 28 | ``` 29 | 30 | This creates a `webhook` service that will listen for incoming webhook events on (docker-internal) port 8000. Note that I have left the `WEBHOOK_SECRET` as a variable even in the `docker-compose.yml`. This is because I have found it handy to encrypt these values in a separate `.env` file with [`git-crypt`](https://github.com/AGWA/git-crypt). 31 | You're able to use `webhook_secret` [Docker secret](https://docs.docker.com/compose/compose-file/#secrets) instead of environment variable to provide this value. 32 | 33 | To route webhook events to the `webhook` image, I will add this snippet to my frontend `nginx` config: 34 | 35 | ``` 36 | location /_webhook { 37 | proxy_pass http://webhook:8000/; 38 | } 39 | ``` 40 | 41 | Finally, within my application code, I will create a directory `hooks` and place executable files such as `bash` shell scripts that will run within there. In this case, I will put a `deploy.sh` file within that directory: 42 | 43 | ```bash 44 | #!/bin/bash 45 | 46 | cd /code 47 | docker-compose build --pull && docker-compose up --build --remove-orphans -d 48 | ``` 49 | 50 | Commands such as `bash`, `make`, `python` and `docker-compose` are available within the `staticfloat/docker-webhook` image, but if you need something more complex than that, you will likely need to add them. 51 | 52 | ## Significant environment variables: 53 | 54 | | Variable | Required | Effect | 55 | | --------------------|----------|------------------------------------------------------------| 56 | | WEBHOOK_SECRET | YES | Defines the secret used for github hook verification | 57 | | WEBHOOK_HOOKS_DIR | NO | Directory where hooks are stored, defaults to `/app/hooks` | 58 | | WEBHOOK_BRANCH_LIST | NO | Comma-separated list of branches, defaults to `master` | 59 | 60 | ## Misc. information 61 | 62 | There is also a `/logs` endpoint that will show the `stdout` and `stderr` of the last execution. 63 | -------------------------------------------------------------------------------- /webhook_listener.py: -------------------------------------------------------------------------------- 1 | import hmac 2 | import logging 3 | from json import dumps 4 | from os import X_OK, access, getenv, listdir 5 | from os.path import join 6 | from pathlib import Path 7 | from subprocess import PIPE, Popen 8 | from sys import stderr, exit 9 | from traceback import print_exc 10 | 11 | from flask import Flask, abort, request 12 | 13 | 14 | def get_secret(name): 15 | """Tries to read Docker secret or corresponding environment variable. 16 | 17 | Returns: 18 | secret (str): Secret value. 19 | 20 | """ 21 | secret_path = Path('/run/secrets/') / name 22 | 23 | try: 24 | with open(secret_path, 'r') as file_descriptor: 25 | # Several text editors add trailing newline which may cause troubles. 26 | # That's why we're trimming secrets' spaces here. 27 | return file_descriptor.read() \ 28 | .strip() 29 | except OSError as err: 30 | variable_name = name.upper() 31 | logging.debug( 32 | 'Can\'t obtain secret %s via %s path. Will use %s environment variable.', 33 | name, 34 | secret_path, 35 | variable_name 36 | ) 37 | return getenv(variable_name) 38 | 39 | 40 | logging.basicConfig(stream=stderr, level=logging.INFO) 41 | 42 | # Collect all scripts now; we don't need to search every time 43 | # Allow the user to override where the hooks are stored 44 | HOOKS_DIR = getenv("WEBHOOK_HOOKS_DIR", "/app/hooks") 45 | scripts = [join(HOOKS_DIR, f) for f in sorted(listdir(HOOKS_DIR))] 46 | scripts = [f for f in scripts if access(f, X_OK)] 47 | if not scripts: 48 | logging.error("No executable hook scripts found; did you forget to" 49 | " mount something into %s or chmod +x them?", HOOKS_DIR) 50 | exit(1) 51 | 52 | # Get application secret 53 | webhook_secret = get_secret('webhook_secret') 54 | if webhook_secret is None: 55 | logging.error("Must define WEBHOOK_SECRET") 56 | exit(1) 57 | 58 | # Get branch list that we'll listen to, defaulting to just 'master' 59 | branch_whitelist = getenv('WEBHOOK_BRANCH_LIST', 'master').split(',') 60 | 61 | # Our Flask application 62 | application = Flask(__name__) 63 | 64 | # Keep the logs of the last execution around 65 | responses = {} 66 | 67 | 68 | @application.route('/', methods=['POST']) 69 | def index(): 70 | global webhook_secret, branch_whitelist, scripts, responses 71 | 72 | # Get signature from the webhook request 73 | header_signature = request.headers.get('X-Hub-Signature') 74 | header_gitlab_token = request.headers.get('X-Gitlab-Token') 75 | if header_signature is not None: 76 | # Construct an hmac, abort if it doesn't match 77 | try: 78 | sha_name, signature = header_signature.split('=') 79 | except: 80 | logging.info("X-Hub-Signature format is incorrect (%s), aborting", header_signature) 81 | abort(400) 82 | data = request.get_data() 83 | try: 84 | mac = hmac.new(webhook_secret.encode('utf8'), msg=data, digestmod=sha_name) 85 | except: 86 | logging.info("Unsupported X-Hub-Signature type (%s), aborting", header_signature) 87 | abort(400) 88 | if not hmac.compare_digest(str(mac.hexdigest()), str(signature)): 89 | logging.info("Signature did not match (%s and %s), aborting", str(mac.hexdigest()), str(signature)) 90 | abort(403) 91 | event = request.headers.get("X-GitHub-Event", "ping") 92 | elif header_gitlab_token is not None: 93 | if webhook_secret != header_gitlab_token: 94 | logging.info("Gitlab Secret Token did not match, aborting") 95 | abort(403) 96 | event = request.headers.get("X-Gitlab-Event", "unknown") 97 | else: 98 | logging.info("X-Hub-Signature was missing, aborting") 99 | abort(403) 100 | 101 | # Respond to ping properly 102 | if event == "ping": 103 | return dumps({"msg": "pong"}) 104 | 105 | # Don't listen to anything but push 106 | if event != "push" and event != "Push Hook": 107 | logging.info("Not a push event, aborting") 108 | abort(403) 109 | 110 | # Try to parse out the branch from the request payload 111 | try: 112 | branch = request.get_json(force=True)["ref"].split("/", 2)[2] 113 | except: 114 | print_exc() 115 | logging.info("Parsing payload failed") 116 | abort(400) 117 | 118 | # Reject branches not in our whitelist 119 | if branch not in branch_whitelist: 120 | logging.info("Branch %s not in branch_whitelist %s", 121 | branch, branch_whitelist) 122 | abort(403) 123 | 124 | # Run scripts, saving into responses (which we clear out) 125 | responses = {} 126 | for script in scripts: 127 | proc = Popen([script, branch], stdout=PIPE, stderr=PIPE) 128 | stdout, stderr = proc.communicate() 129 | stdout = stdout.decode('utf-8') 130 | stderr = stderr.decode('utf-8') 131 | 132 | # Log errors if a hook failed 133 | if proc.returncode != 0: 134 | logging.error('[%s]: %d\n%s', script, proc.returncode, stderr) 135 | 136 | responses[script] = { 137 | 'stdout': stdout, 138 | 'stderr': stderr 139 | } 140 | 141 | return dumps(responses) 142 | 143 | @application.route('/logs', methods=['GET']) 144 | def logs(): 145 | return dumps(responses) 146 | 147 | 148 | # Run the application if we're run as a script 149 | if __name__ == '__main__': 150 | logging.info("All systems operational, beginning application loop") 151 | application.run(debug=False, host='0.0.0.0', port=8000) 152 | --------------------------------------------------------------------------------