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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,4 @@ local-run.sh
.scannerwork/
webhook-server.private-key.pem
log-colors.json
webhook_server/tests/manifests/logs
5 changes: 2 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,8 @@ RUN mkdir -p $BIN_DIR \
&& mkdir -p $DATA_DIR \
&& mkdir -p $DATA_DIR/logs

COPY entrypoint.sh entrypoint.py pyproject.toml uv.lock README.md $APP_DIR/
COPY entrypoint.py pyproject.toml uv.lock README.md $APP_DIR/
COPY webhook_server $APP_DIR/webhook_server/
RUN chmod +x $APP_DIR/entrypoint.sh

RUN usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USERNAME \
&& chown -R $USERNAME:$USERNAME $HOME_DIR
Expand Down Expand Up @@ -67,4 +66,4 @@ RUN uv sync

HEALTHCHECK CMD curl --fail http://127.0.0.1:5000/webhook_server/healthcheck || exit 1

ENTRYPOINT ["./entrypoint.sh"]
ENTRYPOINT ["uv", "run", "entrypoint.py"]
15 changes: 13 additions & 2 deletions entrypoint.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import asyncio

import uvicorn

from webhook_server.libs.config import Config
from webhook_server.utils.github_repository_and_webhook_settings import repository_and_webhook_settings

Expand All @@ -8,6 +12,13 @@
_max_workers = _root_config.get("max-workers", 10)
_webhook_secret = _root_config.get("webhook-secret")


if __name__ == "__main__":
repository_and_webhook_settings(webhook_secret=_webhook_secret)
print(f"uv run uvicorn webhook_server.app:FASTAPI_APP --host {_ip_bind} --port {_port} --workers {_max_workers}")
result = asyncio.run(repository_and_webhook_settings(webhook_secret=_webhook_secret))
uvicorn.run(
"webhook_server.app:FASTAPI_APP",
host=_ip_bind,
port=int(_port),
workers=int(_max_workers),
reload=False,
)
9 changes: 0 additions & 9 deletions entrypoint.sh

This file was deleted.

6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ dependencies = [
"timeout-sampler>=0.0.46",
"uvicorn>=0.31.0",
"httpx>=0.28.1",
"asyncstdlib>=3.13.1",
]

[[project.authors]]
Expand All @@ -82,6 +83,11 @@ dependencies = [
Download = "https://quay.io/repository/myakove/github-webhook-server"
"Bug Tracker" = "https://github.com/myakove/github-webhook-server/issues"

[project.optional-dependencies]
tests = [
"pytest-asyncio>=0.26.0",
]

[build-system]
requires = [ "hatchling" ]
build-backend = "hatchling.build"
2 changes: 1 addition & 1 deletion tox.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ commands = [

[env.unittests]
deps = ["uv"]
commands = [["uv", "run", "pytest", "webhook_server/tests"]]
commands = [["uv", "run", "--extra", "tests", "pytest", "webhook_server/tests"]]
30 changes: 30 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

123 changes: 79 additions & 44 deletions webhook_server/app.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import hashlib
import hmac
import ipaddress
import json
import logging
import os
import sys
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator

import httpx
import requests
import urllib3
from fastapi import (
Expand All @@ -20,15 +22,55 @@
from starlette.datastructures import Headers

from webhook_server.libs.config import Config
from webhook_server.libs.exceptions import NoPullRequestError, RepositoryNotFoundError
from webhook_server.libs.exceptions import RepositoryNotFoundError
from webhook_server.libs.github_api import GithubWebhook
from webhook_server.utils.helpers import get_logger_with_params

ALLOWED_IPS: tuple[ipaddress._BaseNetwork, ...] = ()
LOGGER = get_logger_with_params(name="main")


APP_URL_ROOT_PATH: str = "/webhook_server"
urllib3.disable_warnings()

_lifespan_http_client: httpx.AsyncClient | None = None


async def get_github_allowlist() -> list[str]:
"""Fetch and cache GitHub IP allowlist asynchronously."""
try:
assert _lifespan_http_client is not None
response = await _lifespan_http_client.get("https://api.github.com/meta")
response.raise_for_status() # Check for HTTP errors
data = response.json()
return data.get("hooks", [])

except httpx.RequestError as e:
LOGGER.error(f"Error fetching GitHub allowlist: {e}")
raise

except Exception as e:
LOGGER.error(f"Unexpected error fetching GitHub allowlist: {e}")
raise


async def get_cloudflare_allowlist() -> list[str]:
"""Fetch and cache Cloudflare IP allowlist asynchronously."""
try:
assert _lifespan_http_client is not None
response = await _lifespan_http_client.get("https://api.cloudflare.com/client/v4/ips")
response.raise_for_status()
result = response.json().get("result", {})
return result.get("ipv4_cidrs", []) + result.get("ipv6_cidrs", [])

except httpx.RequestError as e:
LOGGER.error(f"Error fetching Cloudflare allowlist: {e}")
raise

except Exception as e:
LOGGER.error(f"Unexpected error fetching Cloudflare allowlist: {e}")
raise


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.
Expand All @@ -50,22 +92,6 @@ def verify_signature(payload_body: bytes, secret_token: str, signature_header: H
raise HTTPException(status_code=403, detail="Request signatures didn't match!")


def get_github_allowlist() -> list[str]:
"""Fetch and cache GitHub IP allowlist"""
response = requests.get("https://api.github.com/meta", timeout=5)
response.raise_for_status()
data = response.json()
return data.get("hooks", [])


def get_cloudflare_allowlist() -> list[str]:
"""Fetch and cache Cloudflare IP allowlist"""
response = requests.get("https://api.cloudflare.com/client/v4/ips", timeout=5)
response.raise_for_status()
result = response.json()["result"]
return result.get("ipv4_cidrs", []) + result.get("ipv6_cidrs", [])


async def gate_by_allowlist_ips(request: Request) -> None:
if ALLOWED_IPS:
try:
Expand All @@ -85,36 +111,54 @@ async def gate_by_allowlist_ips(request: Request) -> None:

@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger = get_logger_with_params(name="startup")
global _lifespan_http_client
_lifespan_http_client = httpx.AsyncClient(timeout=10.0)

try:
logger.info("Application starting up...")
config = Config(logger=logger)
LOGGER.info("Application starting up...")
config = Config(logger=LOGGER)
root_config = config.root_data
verify_github_ips = root_config.get("verify-github-ips")
verify_cloudflare_ips = root_config.get("verify-cloudflare-ips")
logger.info("Repository and webhook settings initialized successfully.")
LOGGER.debug(f"verify_github_ips: {verify_github_ips}, verify_cloudflare_ips: {verify_cloudflare_ips}")

global ALLOWED_IPS
networks: set[ipaddress._BaseNetwork] = set()

if verify_github_ips or verify_cloudflare_ips:
networks: list[ipaddress._BaseNetwork] = []
if verify_cloudflare_ips:
cf_ips = await get_cloudflare_allowlist()

if verify_cloudflare_ips:
networks += [ipaddress.ip_network(cidr) for cidr in get_cloudflare_allowlist()]
for cidr in cf_ips:
try:
networks.add(ipaddress.ip_network(cidr))
except ValueError:
LOGGER.warning(f"Skipping invalid CIDR from Cloudflare: {cidr}")

if verify_github_ips:
networks += [ipaddress.ip_network(cidr) for cidr in get_github_allowlist()]
if verify_github_ips:
gh_ips = await get_github_allowlist()

ALLOWED_IPS = tuple(networks) # immutable & de-duplicated
for cidr in gh_ips:
try:
networks.add(ipaddress.ip_network(cidr))
except ValueError:
LOGGER.warning(f"Skipping invalid CIDR from Github: {cidr}")

logger.info(f"IP allowlist initialized successfully. {ALLOWED_IPS}")
if networks:
ALLOWED_IPS = tuple(networks)
LOGGER.info(f"IP allowlist initialized successfully with {len(ALLOWED_IPS)} networks.")

Comment on lines 125 to 149
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Reset ALLOWED_IPS when verification flags are disabled or no networks are loaded

If verify-github-ips/verify-cloudflare-ips are turned off (or both endpoints fail),
networks stays empty and ALLOWED_IPS retains the value from a previous run (e.g., in unit-
tests or hot-reload).
A stale allow-list is dangerous because requests will be gated against the old ranges.

-        if networks:
-            ALLOWED_IPS = tuple(networks)
-            LOGGER.info(f"IP allowlist initialized successfully with {len(ALLOWED_IPS)} networks.")
+        if networks:
+            ALLOWED_IPS = tuple(networks)
+            LOGGER.info(
+                "IP allowlist initialized successfully with %s networks.", len(ALLOWED_IPS)
+            )
+        else:
+            ALLOWED_IPS = ()        # clear any previous value
+            LOGGER.info("IP allowlist disabled or empty – all sources accepted.")

yield

except Exception as ex:
logger.error(f"Application failed to start up: {ex}")
LOGGER.error(f"Application failed during lifespan management: {ex}")
raise

finally:
if _lifespan_http_client:
await _lifespan_http_client.aclose()

LOGGER.info("Application shutdown complete.")


FASTAPI_APP: FastAPI = FastAPI(title="webhook-server", lifespan=lifespan)

Expand All @@ -126,12 +170,9 @@ def healthcheck() -> dict[str, Any]:

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

payload_body = await request.body()

config = Config(logger=logger)
config = Config(logger=LOGGER)
root_config = config.root_data
webhook_secret = root_config.get("webhook-secret")

Expand All @@ -145,20 +186,18 @@ async def process_webhook(request: Request, background_tasks: BackgroundTasks) -
log_context = f"[Event: {event_type}][Delivery: {delivery_id}]"

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

except Exception as e:
logger.error(f"{log_context} Error parsing JSON body: {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"])
logger = get_logger_with_params(name="main", repository_name=hook_data["repository"]["name"])

Comment thread
myakove marked this conversation as resolved.
async def process_with_error_handling(_api: GithubWebhook, _logger: logging.Logger) -> None:
try:
await _api.process()

except NoPullRequestError:
return

except Exception as e:
_logger.exception(f"{log_context} Error in background task: {e}")

Expand All @@ -176,10 +215,6 @@ async def process_with_error_handling(_api: GithubWebhook, _logger: logging.Logg
logger.exception(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}"}

except HTTPException:
raise

Expand Down
Loading