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
3 changes: 3 additions & 0 deletions docker-compose-example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ services:
- MAX_WORKERS=50 # Defaults to 10 if not set
- WEBHOOK_SERVER_IP_BIND=0.0.0.0 # IP to listen
- WEBHOOK_SERVER_PORT=5000 # Port to listen
- WEBHOOK_SECRET=<secret> # If set verify hook is a valid hook from Github
- VERIFY_GITHUB_IPS=1 # Verifyhook request is from GitHub IPs
- VERIFY_CLOUDFLARE_IPS=1 # Verify hook request is from Cloudflare IPs
ports:
- "5000:5000"
privileged: true
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ dependencies = [
"uvicorn>=0.31.0",
"uvicorn-worker>=0.3.0",
"gunicorn>=23.0.0",
"httpx>=0.28.1",
]

[[project.authors]]
Expand Down
1,258 changes: 644 additions & 614 deletions uv.lock

Large diffs are not rendered by default.

149 changes: 125 additions & 24 deletions webhook_server/app.py
Original file line number Diff line number Diff line change
@@ -1,63 +1,164 @@
import hashlib
import hmac
import ipaddress
import os
import sys
from functools import lru_cache
from typing import Any

import requests
import urllib3
from fastapi import FastAPI, Request
from fastapi import (
Depends,
FastAPI,
HTTPException,
Request,
status,
)
from httpx import AsyncClient
from starlette.datastructures import Headers

from webhook_server.libs.exceptions import NoPullRequestError, RepositoryNotFoundError
from webhook_server.libs.github_api import ProcessGithubWehook
from webhook_server.utils.github_repository_and_webhook_settings import repository_and_webhook_settings
from webhook_server.utils.helpers import get_logger_with_params

VERIFY_GITHUB_IPS = os.getenv("GITHUB_IPS_ONLY", "").lower() in ["true", "1"]
VERIFY_CLOUDFLARE_IPS = os.getenv("CLOUDFLARE_IPS_ONLY", "").lower() in ["true", "1"]
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "")
FASTAPI_APP: FastAPI = FastAPI(title="webhook-server")
APP_URL_ROOT_PATH: str = "/webhook_server"
urllib3.disable_warnings()


def verify_signature(payload_body: bytes, secret_token: str, signature_header: Headers | None = None) -> None:
"""Verify that the payload was sent from GitHub by validating SHA256.

Raise and return 403 if not authorized.

Args:
payload_body: original request body to verify (request.body())
secret_token: GitHub app webhook token (WEBHOOK_SECRET)
signature_header: header received from GitHub (x-hub-signature-256)
"""
if not signature_header:
raise HTTPException(status_code=403, detail="x-hub-signature-256 header is missing!")

hash_object = hmac.new(secret_token.encode("utf-8"), msg=payload_body, digestmod=hashlib.sha256)
expected_signature = "sha256=" + hash_object.hexdigest()

if not hmac.compare_digest(expected_signature, signature_header):
raise HTTPException(status_code=403, detail="Request signatures didn't match!")


@lru_cache(maxsize=1)
async def get_github_allowlist() -> list[str]:
"""Fetch and cache GitHub IP allowlist"""
async with AsyncClient(timeout=10.0) as client:
response = await client.get("https://api.github.com/meta")
return response.json()["hooks"]


@lru_cache(maxsize=1)
async def get_cloudflare_allowlist() -> list[str]:
"""Fetch and cache Cloudflare IP allowlist"""
async with AsyncClient(timeout=10.0) as client:
response = await client.get("https://api.cloudflare.com/client/v4/ips")
return response.json()["result"]["ipv4_cidrs"]


async def gate_by_allowlist_ips(request: Request) -> None:
if VERIFY_GITHUB_IPS or VERIFY_CLOUDFLARE_IPS:
allowlist = []

try:
src_ip = ipaddress.ip_address(request.client.host)
except ValueError:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Could not hook sender ip address")

if VERIFY_GITHUB_IPS:
github_allowlist = await get_github_allowlist()
allowlist.extend(github_allowlist)

if VERIFY_CLOUDFLARE_IPS:
cloudflare_allowlist = await get_cloudflare_allowlist()
allowlist.extend(cloudflare_allowlist)

if not allowlist:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Failed to get allowlist ips")

for valid_ip in allowlist:
if src_ip in ipaddress.ip_network(valid_ip):
return
else:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Not a GitHub hooks ip address")


def on_starting(server: Any) -> None:
repository_and_webhook_settings()
logger = get_logger_with_params(name="startup")
logger.info("Application starting up...")
try:
repository_and_webhook_settings(webhook_secret=WEBHOOK_SECRET)
logger.info("Repository and webhook settings initialized successfully.")

except Exception as ex:
logger.exception(f"FATAL: Error during startup initialization: {ex}")
raise


@FASTAPI_APP.get(f"{APP_URL_ROOT_PATH}/healthcheck")
def healthcheck() -> dict[str, Any]:
return {"status": requests.codes.ok, "message": "Alive"}


@FASTAPI_APP.post(APP_URL_ROOT_PATH)
@FASTAPI_APP.post(APP_URL_ROOT_PATH, dependencies=[Depends(gate_by_allowlist_ips)])
async def process_webhook(request: Request) -> dict[str, Any]:
logger_name: str = "main"
logger = get_logger_with_params(name=logger_name)

payload_body = await request.body()

if WEBHOOK_SECRET:
signature_header = request.headers.get("x-hub-signature-256")
verify_signature(payload_body=payload_body, secret_token=WEBHOOK_SECRET, signature_header=signature_header)

delivery_id = request.headers.get("X-GitHub-Delivery", "unknown-delivery")
event_type = request.headers.get("X-GitHub-Event", "unknown-event")
delivery_headers = request.headers.get("X-GitHub-Delivery", "")
process_failed_msg: dict[str, Any] = {
"status": requests.codes.server_error,
"message": "Process failed",
"log_prefix": delivery_headers,
}
log_context = f"[Event: {event_type}][Delivery: {delivery_id}]"

try:
hook_data: dict[Any, Any] = await request.json()

except Exception as ex:
logger.error(f"Error get JSON from request: {ex}")
return process_failed_msg
except Exception as e:
logger.error(f"{log_context} Error parsing JSON body: {e}")
raise HTTPException(status_code=400, detail="Invalid JSON payload")

logger = get_logger_with_params(name=logger_name, repository_name=hook_data["repository"]["name"])

try:
api: ProcessGithubWehook = ProcessGithubWehook(hook_data=hook_data, headers=request.headers, logger=logger)
api.process()
return {"status": requests.codes.ok, "message": "process success", "log_prefix": delivery_headers}

except Exception as exp:
logger.error(f"Error: {exp}")
exc_type, exc_obj, exc_tb = sys.exc_info() # noqa: F841
msg = f"Error: {exc_type}"
except RepositoryNotFoundError as e:
logger.error(f"{log_context} Configuration/Repository error: {e}")
raise HTTPException(status_code=404, detail=str(e))

except ConnectionError as e:
logger.error(f"{log_context} API connection error: {e}")
raise HTTPException(status_code=503, detail=f"API Connection Error: {e}")

except NoPullRequestError as e:
logger.debug(f"{log_context} Processing skipped: {e}")
return {"status": "OK", "message": f"Processing skipped: {e}"}

if exc_tb is not None:
file_name = os.path.split(exc_tb.tb_frame.f_code.co_filename)
msg = f"Error: {exc_type}, File: {file_name}, Line: {exc_tb.tb_lineno}"
except HTTPException:
raise

return {
"status": requests.codes.server_error,
"message": msg,
"log_prefix": delivery_headers,
}
except Exception as e:
logger.exception(f"{log_context} Unexpected error during processing: {e}")
exc_type, _, exc_tb = sys.exc_info()
line_no = exc_tb.tb_lineno if exc_tb else "unknown"
file_name = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] if exc_tb else "unknown"
error_details = f"Error type: {exc_type.__name__ if exc_type else ''}, File: {file_name}, Line: {line_no}"
raise HTTPException(status_code=500, detail=f"Internal Server Error: {error_details}")
12 changes: 12 additions & 0 deletions webhook_server/libs/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class NoPullRequestError(Exception):
pass


class RepositoryNotFoundError(Exception):
pass


class ProcessGithubWebhookError(Exception):
def __init__(self, err: dict[str, str]):
self.err = err
super().__init__(str(err))
45 changes: 19 additions & 26 deletions webhook_server/libs/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import requests
import shortuuid
import yaml
from fastapi.exceptions import HTTPException
from github import GithubException
from github.Branch import Branch
from github.CheckRun import CheckRun
Expand All @@ -27,6 +28,7 @@
from timeout_sampler import TimeoutExpiredError, TimeoutSampler

from webhook_server.libs.config import Config
from webhook_server.libs.exceptions import NoPullRequestError, RepositoryNotFoundError
from webhook_server.utils.constants import (
ADD_STR,
APPROVE_STR,
Expand Down Expand Up @@ -80,22 +82,6 @@
)


class NoPullRequestError(Exception):
pass


class RepositoryNotFoundError(Exception):
pass


class ProcessGithubWehookError(Exception):
def __init__(self, err: dict[str, str]):
self.err = err

def __str__(self) -> str:
return f"{self.err}"


class ProcessGithubWehook:
def __init__(self, hook_data: dict[Any, Any], headers: Headers, logger: logging.Logger) -> None:
self.logger = logger
Expand Down Expand Up @@ -166,9 +152,9 @@ def __init__(self, hook_data: dict[Any, Any], headers: Headers, logger: logging.
"Report bugs in [Issues](https://github.com/myakove/github-webhook-server/issues)"
)

def process(self) -> None:
def process(self) -> Any:
if self.github_event == "ping":
return
return {"status": requests.codes.ok, "message": "pong"}

event_log: str = f"Event type: {self.github_event}. event ID: {self.x_github_delivery}"

Expand All @@ -186,22 +172,28 @@ def process(self) -> None:
self.all_reviewers = self.get_all_reviewers()

if self.github_event == "issue_comment":
self.process_comment_webhook_data()
return self.process_comment_webhook_data()

elif self.github_event == "pull_request":
self.process_pull_request_webhook_data()
if self.github_event == "pull_request":
return self.process_pull_request_webhook_data()

elif self.github_event == "pull_request_review":
self.process_pull_request_review_webhook_data()
if self.github_event == "pull_request_review":
return self.process_pull_request_review_webhook_data()

elif self.github_event == "check_run":
self.process_pull_request_check_run_webhook_data()
if self.github_event == "check_run":
return self.process_pull_request_check_run_webhook_data()

except NoPullRequestError:
self.logger.debug(f"{self.log_prefix} {event_log}. [No pull request found in hook data]")

if self.github_event == "push":
self.process_push_webhook_data()
return self.process_push_webhook_data()

raise

except Exception as e:
self.logger.error(f"{self.log_prefix} {event_log}. Exception: {e}")
raise HTTPException(status_code=404, detail=str(e))

@property
def _prepare_retest_welcome_comment(self) -> str:
Expand Down Expand Up @@ -281,6 +273,7 @@ def _get_random_color(_colors: list[str], _json: dict[str, str]) -> str:

def prepare_log_prefix(self, pull_request: PullRequest | None = None) -> str:
_repository_color = self._get_reposiroty_color_for_log_prefix()

return (
f"{_repository_color}[{self.github_event}][{self.x_github_delivery}][{self.api_user}][PR {pull_request.number}]:"
if pull_request
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def get_repository_api(repository: str) -> tuple[str, github.Github | None, str]
return repository, github_api, api_user


def repository_and_webhook_settings() -> None:
def repository_and_webhook_settings(webhook_secret: str | None = None) -> None:
logger = get_logger_with_params(name="github-repository-and-webhook-settings")

config = Config()
Expand All @@ -42,8 +42,4 @@ def repository_and_webhook_settings() -> None:

set_repositories_settings(config=config, apis_dict=apis_dict)
set_all_in_progress_check_runs_to_queued(repo_config=config, apis_dict=apis_dict)
create_webhook(config=config, apis_dict=apis_dict)


if __name__ == "__main__":
repository_and_webhook_settings()
create_webhook(config=config, apis_dict=apis_dict, secret=webhook_secret)
Loading