Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
__pycache__/

.env
config.json

.directory

Expand Down
51 changes: 36 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,52 +1,73 @@
# 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`.

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/
191 changes: 87 additions & 104 deletions aas.py
Original file line number Diff line number Diff line change
@@ -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...')
16 changes: 16 additions & 0 deletions sample.config.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
10 changes: 0 additions & 10 deletions sample.env

This file was deleted.