Last active 1 week ago

Github webhook proxy server (only Issues implemented)

onyx_online's Avatar onyx_online revised this gist 1 week ago. Go to revision

2 files changed, 11 insertions, 4 deletions

handlers.py

@@ -20,7 +20,7 @@ async def issues(payload, request_client: AsyncSession):
20 20 "description": payload["issue"].get("body"),
21 21 }
22 22 data = {
23 - "username": "Github",
23 + "username": "Github-OpenGist",
24 24 "embeds": [embed],
25 25 }
26 26 await send_discord_webhook(request_client=request_client, data=data)

main.py

@@ -4,7 +4,11 @@ import niquests
4 4 import logging
5 5
6 6 import handlers
7 - from dependencies import get_request_client, validate_source_ip, validate_webhook_secret
7 + from dependencies import (
8 + get_request_client,
9 + validate_source_ip,
10 + validate_webhook_secret,
11 + )
8 12
9 13 logger = logging.getLogger(__name__)
10 14
@@ -21,7 +25,10 @@ app = FastAPI(lifespan=lifespan)
21 25
22 26 @app.post(
23 27 "/github/{repo_name}",
24 - dependencies=[Depends(validate_source_ip), Depends(validate_webhook_secret)],
28 + dependencies=[
29 + Depends(validate_source_ip),
30 + Depends(validate_webhook_secret),
31 + ],
25 32 )
26 33 async def github_webhook(
27 34 repo_name: str,
@@ -34,7 +41,7 @@ async def github_webhook(
34 41 payload = await request.json()
35 42 match x_github_event:
36 43 case "ping":
37 - return {'message': 'pong'}
44 + return {"message": "pong"}
38 45 case "issues":
39 46 await handlers.issues(payload, client)
40 47 case _:

onyx_online's Avatar onyx_online revised this gist 1 week ago. Go to revision

1 file changed, 1 insertion, 1 deletion

main.py

@@ -24,8 +24,8 @@ app = FastAPI(lifespan=lifespan)
24 24 dependencies=[Depends(validate_source_ip), Depends(validate_webhook_secret)],
25 25 )
26 26 async def github_webhook(
27 - request: Request,
28 27 repo_name: str,
28 + request: Request,
29 29 client: niquests.AsyncSession = Depends(get_request_client),
30 30 x_real_ip: str = Header(...),
31 31 x_github_event: str = Header(...),

onyx_online's Avatar onyx_online revised this gist 1 week ago. Go to revision

2 files changed, 3 insertions, 3 deletions

dependencies.py

@@ -15,7 +15,7 @@ def get_request_client(request: Request) -> niquests.AsyncSession:
15 15 return request.app.state.request_client
16 16
17 17
18 - async def validate_github_ip(
18 + async def validate_source_ip(
19 19 request_client: Annotated[niquests.AsyncSession, Depends(get_request_client)],
20 20 x_real_ip: str = Header(...),
21 21 ) -> None:

main.py

@@ -4,7 +4,7 @@ import niquests
4 4 import logging
5 5
6 6 import handlers
7 - from dependencies import get_request_client, validate_github_ip, validate_webhook_secret
7 + from dependencies import get_request_client, validate_source_ip, validate_webhook_secret
8 8
9 9 logger = logging.getLogger(__name__)
10 10
@@ -21,7 +21,7 @@ app = FastAPI(lifespan=lifespan)
21 21
22 22 @app.post(
23 23 "/github/{repo_name}",
24 - dependencies=[Depends(validate_github_ip), Depends(validate_webhook_secret)],
24 + dependencies=[Depends(validate_source_ip), Depends(validate_webhook_secret)],
25 25 )
26 26 async def github_webhook(
27 27 request: Request,

onyx_online's Avatar onyx_online revised this gist 1 week ago. Go to revision

1 file changed, 7 insertions, 2 deletions

__main__.py renamed to main.py

@@ -4,7 +4,7 @@ import niquests
4 4 import logging
5 5
6 6 import handlers
7 - from dependencies import get_request_client
7 + from dependencies import get_request_client, validate_github_ip, validate_webhook_secret
8 8
9 9 logger = logging.getLogger(__name__)
10 10
@@ -19,7 +19,10 @@ async def lifespan(app: FastAPI):
19 19 app = FastAPI(lifespan=lifespan)
20 20
21 21
22 - @app.post("/github/{repo_name}")
22 + @app.post(
23 + "/github/{repo_name}",
24 + dependencies=[Depends(validate_github_ip), Depends(validate_webhook_secret)],
25 + )
23 26 async def github_webhook(
24 27 request: Request,
25 28 repo_name: str,
@@ -30,6 +33,8 @@ async def github_webhook(
30 33 ):
31 34 payload = await request.json()
32 35 match x_github_event:
36 + case "ping":
37 + return {'message': 'pong'}
33 38 case "issues":
34 39 await handlers.issues(payload, client)
35 40 case _:

onyx_online's Avatar onyx_online revised this gist 1 week ago. Go to revision

4 files changed, 122 insertions, 70 deletions

__main__.py

@@ -1,81 +1,40 @@
1 - import hashlib
2 - import hmac
3 - import json
4 - import os
5 - from ipaddress import ip_address, ip_network
1 + from fastapi import FastAPI, Request, Depends, Header
2 + from contextlib import asynccontextmanager
3 + import niquests
4 + import logging
6 5
7 - import requests
8 - from dotenv import load_dotenv
9 - from flask import Flask, abort, request
6 + import handlers
7 + from dependencies import get_request_client
10 8
9 + logger = logging.getLogger(__name__)
11 10
12 - def create_app():
13 - app = Flask(__name__)
14 - load_dotenv()
15 - app.config["GITHUB_SECRET"] = os.getenv("GITHUB_SECRET")
16 - app.config["DISCORD_WEBHOOK"] = os.getenv("DISCORD_WEBHOOK")
17 11
18 - return app
12 + @asynccontextmanager
13 + async def lifespan(app: FastAPI):
14 + app.state.request_client = niquests.AsyncSession()
15 + yield
16 + await app.state.request_client.close()
19 17
20 18
21 - app = create_app()
19 + app = FastAPI(lifespan=lifespan)
22 20
23 21
24 - def verify_src_ip(src_ip):
25 - allowed_ips = requests.get("https://api.github.com/meta").json()["hooks"]
26 - return any(src_ip in ip_network(valid_ip) for valid_ip in allowed_ips)
27 -
28 -
29 - def verify_hmac_hash(data, signature):
30 - github_secret = bytes(app.config["GITHUB_SECRET"], "ascii")
31 - mac = hmac.new(github_secret, msg=data, digestmod=hashlib.sha1)
32 - return hmac.compare_digest("sha1=" + mac.hexdigest(), signature)
33 -
34 -
35 - @app.route("/github-payload", methods=["POST"])
36 - def github_payload():
37 - src_ip = ip_address(request.headers.get("X-Real-IP", request.access_route[0]))
38 - if not verify_src_ip(src_ip):
39 - app.logger.debug(f"invalid source ip: {src_ip}... aborting request")
40 - abort(403)
41 -
42 - signature = request.headers.get("X-Hub-Signature")
43 - data = request.data
44 - if not verify_hmac_hash(data, signature):
45 - app.logger.debug("failed to verify signature... aborting request")
46 - abort(403)
47 -
48 - payload = request.get_json()
49 - match event_type := request.headers.get("X-GitHub-Event"):
50 - case "ping":
51 - return json.dumps({"msg": "pong"})
22 + @app.post("/github/{repo_name}")
23 + async def github_webhook(
24 + request: Request,
25 + repo_name: str,
26 + client: niquests.AsyncSession = Depends(get_request_client),
27 + x_real_ip: str = Header(...),
28 + x_github_event: str = Header(...),
29 + x_hub_signature: str = Header(...),
30 + ):
31 + payload = await request.json()
32 + match x_github_event:
52 33 case "issues":
53 - issues_handler(payload)
34 + await handlers.issues(payload, client)
54 35 case _:
55 - app.logger.debug(f"no handler defined for event {event_type}... continuing")
56 - return ("", 200, None)
57 -
58 -
59 - def issues_handler(payload):
60 - embed = {
61 - "author": {
62 - "name": payload["sender"]["login"],
63 - "icon_url": payload["sender"]["avatar_url"],
64 - },
65 - "title": f"[{payload['repository']['full_name']}] Issue {payload['action'].capitalize()}: #{payload['issue']['number']} {payload['issue']['title']}",
66 - "url": payload["issue"]["html_url"],
67 - "description": payload["issue"].get("body"),
68 - }
69 - data = {
70 - "username": "Github-OpenGist",
71 - "embeds": [embed],
36 + logger.debug(f"unhandled event {x_github_event}")
37 + return {
38 + "message": f"received webhook for {repo_name}: {x_github_event}",
39 + "status": "success",
72 40 }
73 - send_discord_webhook(app.config["DISCORD_WEBHOOK"], data)
74 -
75 -
76 - def send_discord_webhook(webhook_url, data):
77 - requests.post(webhook_url, json=data)
78 -
79 -
80 - if __name__ == "__main__":
81 - app.run(debug=True, port=5000)

dependencies.py(file created)

@@ -0,0 +1,47 @@
1 + from fastapi import Request, Header, Depends
2 + import niquests
3 + from fastapi import HTTPException
4 + import logging
5 + import hashlib
6 + import hmac
7 + from settings import settings
8 + from ipaddress import ip_address, ip_network
9 + from typing import Annotated
10 +
11 + logger = logging.getLogger(__name__)
12 +
13 +
14 + def get_request_client(request: Request) -> niquests.AsyncSession:
15 + return request.app.state.request_client
16 +
17 +
18 + async def validate_github_ip(
19 + request_client: Annotated[niquests.AsyncSession, Depends(get_request_client)],
20 + x_real_ip: str = Header(...),
21 + ) -> None:
22 + resp = await request_client.get("https://api.github.com/meta")
23 + allowed_ips = resp.json().get("hooks", [])
24 + if not any(
25 + ip_address(x_real_ip) in ip_network(valid_ip) for valid_ip in allowed_ips
26 + ):
27 + logger.debug(f"Received request from invalid IP: {x_real_ip}")
28 + ERR_MSG = f"IP address {x_real_ip} is not allowed"
29 + raise HTTPException(status_code=403, detail=ERR_MSG)
30 +
31 +
32 + async def validate_webhook_secret(
33 + request: Request, x_hub_signature: str = Header(...)
34 + ) -> None:
35 + if not x_hub_signature:
36 + logger.debug("Missing X-Hub-Signature header")
37 + raise HTTPException(status_code=400, detail="Missing X-Hub-Signature header")
38 +
39 + mac = hmac.new(
40 + settings.GITHUB_WEBHOOK_SECRET.encode(),
41 + msg=await request.body(),
42 + digestmod=hashlib.sha1,
43 + )
44 +
45 + if not hmac.compare_digest("sha1=" + mac.hexdigest(), x_hub_signature):
46 + logger.debug("Invalid HMAC signature")
47 + raise HTTPException(status_code=403, detail="Invalid HMAC signature")

handlers.py(file created)

@@ -0,0 +1,33 @@
1 + from niquests import AsyncSession
2 + from settings import settings
3 + import logging
4 + from fastapi import HTTPException
5 +
6 + logger = logging.getLogger(__name__)
7 +
8 +
9 + async def issues(payload, request_client: AsyncSession):
10 + if payload["action"] not in ["opened", "closed", "reopened", "deleted"]:
11 + return
12 +
13 + embed = {
14 + "author": {
15 + "name": payload["sender"]["login"],
16 + "icon_url": payload["sender"]["avatar_url"],
17 + },
18 + "title": f"[{payload['repository']['full_name']}] Issue {payload['action'].capitalize()}: #{payload['issue']['number']} {payload['issue']['title']}",
19 + "url": payload["issue"]["html_url"],
20 + "description": payload["issue"].get("body"),
21 + }
22 + data = {
23 + "username": "Github",
24 + "embeds": [embed],
25 + }
26 + await send_discord_webhook(request_client=request_client, data=data)
27 +
28 +
29 + async def send_discord_webhook(request_client: AsyncSession, data):
30 + resp = await request_client.post(settings.DISCORD_WEBHOOK_URL, json=data)
31 + if resp.status_code != 204:
32 + raise HTTPException(status_code=400, detail="Failed to send Discord webhook")
33 + logger.info(f"Discord webhook sent with status code {resp.status_code}")

settings.py(file created)

@@ -0,0 +1,13 @@
1 + from pydantic_settings import BaseSettings, SettingsConfigDict
2 +
3 +
4 + class Settings(BaseSettings):
5 + """Application settings."""
6 +
7 + GITHUB_WEBHOOK_SECRET: str
8 + DISCORD_WEBHOOK_URL: str
9 +
10 + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
11 +
12 +
13 + settings = Settings()

onyx_online's Avatar onyx_online revised this gist 2 years ago. Go to revision

1 file changed, 1 insertion, 1 deletion

__main__.py

@@ -42,7 +42,7 @@ def github_payload():
42 42 signature = request.headers.get("X-Hub-Signature")
43 43 data = request.data
44 44 if not verify_hmac_hash(data, signature):
45 - app.logger.debug("failed to verify signature!")
45 + app.logger.debug("failed to verify signature... aborting request")
46 46 abort(403)
47 47
48 48 payload = request.get_json()

onyx_online's Avatar onyx_online revised this gist 2 years ago. Go to revision

1 file changed, 1 insertion, 1 deletion

__main__.py

@@ -70,7 +70,7 @@ def issues_handler(payload):
70 70 "username": "Github-OpenGist",
71 71 "embeds": [embed],
72 72 }
73 - send_discord_webhook(app.config["DISCORD_WEBHOOK"], data=data)
73 + send_discord_webhook(app.config["DISCORD_WEBHOOK"], data)
74 74
75 75
76 76 def send_discord_webhook(webhook_url, data):

onyx_online's Avatar onyx_online revised this gist 2 years ago. Go to revision

1 file changed, 1 insertion, 1 deletion

__main__.py

@@ -52,7 +52,7 @@ def github_payload():
52 52 case "issues":
53 53 issues_handler(payload)
54 54 case _:
55 - app.logger.debug(f"no action defined for event {event_type}... continuing")
55 + app.logger.debug(f"no handler defined for event {event_type}... continuing")
56 56 return ("", 200, None)
57 57
58 58

onyx_online's Avatar onyx_online revised this gist 2 years ago. Go to revision

1 file changed, 1 insertion, 1 deletion

__main__.py

@@ -34,7 +34,7 @@ def verify_hmac_hash(data, signature):
34 34
35 35 @app.route("/github-payload", methods=["POST"])
36 36 def github_payload():
37 - src_ip = ip_address(request.headers.get("X-Real-IP", , request.access_route[0]))
37 + src_ip = ip_address(request.headers.get("X-Real-IP", request.access_route[0]))
38 38 if not verify_src_ip(src_ip):
39 39 app.logger.debug(f"invalid source ip: {src_ip}... aborting request")
40 40 abort(403)

onyx_online's Avatar onyx_online revised this gist 2 years ago. Go to revision

1 file changed, 1 insertion, 1 deletion

__main__.py

@@ -34,7 +34,7 @@ def verify_hmac_hash(data, signature):
34 34
35 35 @app.route("/github-payload", methods=["POST"])
36 36 def github_payload():
37 - src_ip = ip_address(request.headers.get("X-Real-IP"))
37 + src_ip = ip_address(request.headers.get("X-Real-IP", , request.access_route[0]))
38 38 if not verify_src_ip(src_ip):
39 39 app.logger.debug(f"invalid source ip: {src_ip}... aborting request")
40 40 abort(403)
Newer Older