diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6bb3efb..811d931 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,13 +8,13 @@ jobs: runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v2" + - uses: "actions/checkout@v4" - name: "install Nix" - uses: "cachix/install-nix-action@v12" + uses: "cachix/install-nix-action@v31" - name: "use project cache" - uses: cachix/cachix-action@v10 + uses: cachix/cachix-action@v16 with: name: svsticky-aas authToken: ${{ secrets.CACHIX_API_KEY }} diff --git a/.gitignore b/.gitignore index 699fb17..7d11025 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ __pycache__/ .env +config.json .directory diff --git a/README.md b/README.md index eb0d558..94472ce 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,57 @@ # AAS A webhook listener for use at Study Association Sticky, written as a Flask application. -Its current use is to make automatic deployment possible of [our website][static-sticky] in our [production environment][sadserver], triggered by GitHub pushes or changes in the Contentful CMS it uses. +Its current use is to make automatic deployment to [our server][sadserver] +possible of [our website][static-sticky] and our [sign-up page][intro], +triggered by GitHub pushes or changes in the Contentful CMS it uses. + +This project can be extended for other webhook processing. # Usage This project requires Python 3.7+ and [Nix][nix]. ## Development ```console -$ # Use the provided sample.env to create a .env file and populate the environment variables -$ cp sample.env .env -$ vim .env -$ # Open a shell with Aas' development dependencies available -$ nix-shell -$ # Run the development server -$ python aas.py +# Use the provided 'sample.config.json' to create a 'config.json' +# file and adjust it to your needs. (See 'Configuration' section) +cp sample.config.json config.json +vim config.json +# Open a shell with Aas' development dependencies available +nix-shell +# Run the development server +python aas.py ``` The version of the Python dependencies is determined by the snapshot of the Nixpkgs package set. -To update it to a newer snapshot, execute `niv update` inside the `nix-shell` and re-open the shell. +To update it to a newer snapshot, execute `niv update` inside the `nix-shell` +and re-open the shell. (TODO instructions might be outdated) + +## Configuration + +Aas is build to support multiple types of webhooks. +For every type of endpoint you can define a class (webhook handler) +which will handle all requests to that endpoint. For example, you +can define a webhook handler which runs a systemd service on incoming webhooks. + +To abstract your endpoints from this implementation, aas loads up your endpoint +config from `config.json`. This json contains a mappings from endpoints to +webhook handlers. This means that if you ever want to add an endpoint, you simply +expand the `config.json`. ## Production The dependencies include `gunicorn`, which is a WSGI server for use in production. You can use the following commands as a simple example: ``` -$ nix-shell default.nix --run "gunicorn aas:aas" +nix-shell default.nix --run "gunicorn aas:aas" ``` -This binds `gunicorn` to http://localhost:8000/. You should place a reverse proxy, like `nginx`, in front of this. +This binds `gunicorn` to http://localhost:8000/. You should place a reverse proxy, +like `nginx`, in front of this. Alternatively, to not need to invoke `nix` when the server is to be started, you can create a "virtualenv" containing only the production dependencies with: ```console -$ nix-build -o aas-env +nix-build -o aas-env ``` The server can then be started by running `aas-env/bin/gunicorn aas:aas`. @@ -40,13 +59,15 @@ The server can then be started by running `aas-env/bin/gunicorn aas:aas`. To set this up in our own production environment, we use some [Ansible tasks][sadserver-aas]. # Testing consumption of webhooks in development -To make the development server temporarily available for webhook consumers in the outside world (e.g. GitHub), you can use [`ngrok`][ngrok]. +To make the development server temporarily available for webhook consumers in +the outside world (e.g. GitHub), you can use [`ngrok`][ngrok]. `ngrok` is automatically made available when you run `nix-shell`. -To use it, you can [run](#development) `aas` in one terminal and in another terminal run `ngrok http 5000` (`5000` being the default port Flask's development server binds to). +To use it, you can [run](#development) `aas` in one terminal and in another terminal +run `ngrok http 5000` (`5000` being the default port Flask's development server binds to). [static-sticky]: http://github.com/svsticky/static-sticky + [intro]: http://github.com/svsticky/intro-website [sadserver]: https://github.com/svsticky/sadserver/ - [pipenv]: https://nixos.org [sadserver-aas]: https://github.com/svsticky/sadserver/blob/master/ansible/tasks/aas.yml [ngrok]: https://ngrok.com/ diff --git a/aas.py b/aas.py index 3d2d8ce..572cb94 100644 --- a/aas.py +++ b/aas.py @@ -1,129 +1,112 @@ -import concurrent.futures import hashlib import hmac import json import subprocess -import os -import requests -from flask import Flask, request, Response, abort +from flask import Flask, request, abort from flask_restful import Api, Resource +def run_systemd(service_name): + """Runs a single systemd service on the system.""" -def deploy_static_sticky(): - deploy_service = os.getenv("DEPLOY_SERVICE") - # Make sure the user running this has root privileges to start this command, e.g. by adding it to a sudoers file + print(f"Starting systemd service '{service_name}'") + + # Make sure the user running this has root privileges to start this command, + # e.g. by adding it to a sudoers file for this specific command subprocess.run( [ "sudo", "/usr/bin/systemd-run", "--no-block", "--property", - f"After={deploy_service}", + f"After={service_name}", "--", "systemctl", "start", - deploy_service, + service_name, ], check=True, ) +def create_systemd_handler(service_name, preshared_key): + """ + This function is a wrapper for Flask, for flask cannot assign an instance of a class + to an endpoint, and instead wants to create an instance on its own. We thus tell him here how! + """ + + instance_name = f'RunSystemdHandler {service_name}' + class RunSystemdHandler(Resource): + """ + Run a specified systemd service on incoming authenticated webhooks. + Requests are authenticated with a pre-shared key, known to both Aas and + the calling party. + """ + + def __init__(self): + super().__init__() + self.service_name = service_name + self.preshared_key = preshared_key.encode() + self.__name__ = instance_name # Needed for Flask, but useful anyway + print(f" Created handler '{self.__name__}'") + + def post(self): + print(f"{self.__name__}: responding to webhook!") + + ## Check authentication ## + signature_header_name = "X-Hub-Signature-256" + signature_header = request.headers.get(signature_header_name) + if signature_header is None or len(signature_header) == 0: + print(f"{self.__name__}: Warning: The webhook request did not provide a '{signature_header_name}' header.") + print(f"{self.__name__}: Responding with 403.") + abort(403) + + # remove the sha256= prefix of the signature + # TODO: find more elegant way to do this + signature = signature_header[7:] # Signature is an HMAC hexdigest of the request body with preshared key + + # Test if webhook is authenticated with known secret + if not hmac.compare_digest( + signature, # Already hashed like below, github action does this for us + hmac.new(self.preshared_key, request.get_data(), hashlib.sha256).hexdigest(), + ): + print(f'{self.__name__}: Warning: The webhook request could not authenticate itself.') + print(f"{self.__name__}: The signature received ended with '{signature[-4:]}'") + abort(401) + + print(f"{self.__name__}: Authenticated! Starting systemd service.") + + run_systemd(self.service_name) + return('Yoink!') + + RunSystemdHandler.__name__ = instance_name # Prevents duplicate assignments to same endpoint (Flask stuff) + return RunSystemdHandler + + +def load_webhooks(api): + """Dynamically sets up all webhook handlers based on the 'config.json'.""" + with open('config.json') as f: config = json.load(f) + print('Parsing the following config:') + print(config) + print('') + + # Load all RunSystemd webhook handlers + for webhook in config["webhookHandlers"]["runSystemd"]: + service_name = webhook["serviceName"] + endpoint = webhook["endpoint"] + pre_shared_key = webhook["pre-sharedKey"] + + print(f"Subscribing a systemd handler to endpoint '{endpoint}' for service '{service_name}'") + print(f" The webhook expects authentication with a pre-shared key ending with '{pre_shared_key[-4:]}'") + api.add_resource(create_systemd_handler(service_name, pre_shared_key), endpoint) -class GitHub(Resource): - # Share this secret with GitHub to authenticate this hook - SECRET = os.environ["GITHUB_SECRET"].encode() - - def post(self): - ## Check authentication ## - # remove the sha1= prefix of the signature - # TODO: find more elegant way to do this - signature = request.headers.get("X-Hub-Signature")[5:] - - if not hmac.compare_digest( - signature, - hmac.new(self.SECRET, request.get_data(), hashlib.sha1).hexdigest(), - ): - abort(401) - - response_payload = request.get_json() - - deploy_static_sticky() - - -class Pretix(Resource): - TOKEN = os.environ["PRETIX_TOKEN"] - - def post(self): - payload = request.get_json() - - url = ( - f'https://pretix.svsticky.nl/api/v1/organizers/{payload["organizer"]}/' - f'events/{payload["event"]}/orders/{payload["code"]}/' - ) - - response = requests.get(url, headers={"Authorization": f"Token {self.TOKEN}"}) - - response.raise_for_status() - data = response.json() - - position = data["positions"][0] - - answers = {} - - for answer in position["answers"]: - identifier = answer["question_identifier"] - value = answer["answer"] - - answers[identifier] = value - - if answers.get("aeskwadraat_signup") != "True": - return Response(status=204) - - aes_studie = { - "Informatica": "IC", - "Informatiekunde": "IK", - "Dubbele bachelor Informatica/Informatiekunde": "IC/IK", - }.get(answers.get("studies")) - - voornaam = position["attendee_name_parts"]["given_name"] - achternaam = position["attendee_name_parts"]["family_name"] - - email = data["email"] - - payload = { - "email": email, - "voornaam": voornaam, - "tussenvoegsel": "", - "achternaam": achternaam, - "geboortedatum": answers.get("geboortedatum"), - "studentnummer": answers.get("studentnummer"), - "straat": "unknown", - "huisnummer": "unknown", - "postcode": "unknown", - "plaats": "unknown", - "mobiel": data["phone"], - "studie": aes_studie, - } - - if data.get("testmode"): - aas.logger.warning(f"Got a test mode signup: {payload}") - else: - response = requests.get( - "https://www.a-eskwadraat.nl/Leden/Intro/Aanmelden", params=payload - ) - - response.raise_for_status() - - return Response(status=201) - - -aas = Flask(__name__) -aas_api = Api(aas, catch_all_404s=True) - -contentful_endpoint = os.getenv("CONTENTFUL_SECRET_ENDPOINT", "missing") +if __name__ == "__main__": + print('Aas is starting...\n') -aas_api.add_resource(GitHub, "/webhook/github") -aas_api.add_resource(Pretix, "/webhook/pretix") + aas = Flask(__name__) + aas_api = Api(aas, catch_all_404s=True) + load_webhooks(aas_api) -if __name__ == "__main__": + print('\nBegining to listen to webhooks!\n') aas.run() + + print('\nAas is closing...') diff --git a/sample.config.json b/sample.config.json new file mode 100644 index 0000000..e5498a5 --- /dev/null +++ b/sample.config.json @@ -0,0 +1,16 @@ +{ + "webhookHandlers": { + "runSystemd": [ + { + "serviceName": "prepare-icecream.service", + "endpoint": "/webhook/summer", + "pre-sharedKey": "511ae7e5b6920d12ec5fa6054b7beeb06a0f51dd" + }, + { + "serviceName": "kernel-panic.service", + "endpoint": "/out-of-icecream", + "pre-sharedKey": "MOREICECREAMDAMNIT" + } + ] + } +} \ No newline at end of file diff --git a/sample.env b/sample.env deleted file mode 100644 index 63c9c34..0000000 --- a/sample.env +++ /dev/null @@ -1,10 +0,0 @@ -FLASK_APP=main.py -FLASK_ENV=development - -GITHUB_SECRET= -CONTENTFUL_SECRET_ENDPOINT= - -PRETIX_TOKEN= - -DEPLOY_SERVICE=test.service -DEPLOY_REF=master