Как проверить GitHub webhook на localhost через HTTPS

Практическое руководство: принять POST от GitHub на localhost, настроить HTTPS-туннель DevProxy, проверить X-Hub-Signature-256 и ответ 2xx.

Обновлено:

Содержание

Вы получите входящий POST от GitHub на локальном адресе, проверите подпись запроса и увидите ответ 2xx. Настройка состоит из пяти коротких шагов: запустить приёмник, проверить его локально, поднять отдельный тестовый HTTP-туннель DevProxy, добавить адрес в GitHub и открыть отчёт о доставке.

GitHub  →  https://АКТУАЛЬНЫЙ-АДРЕС-ТУННЕЛЯ/webhook
        →  DevProxy
        →  http://127.0.0.1:8765/webhook
        →  204 No Content

Подпись X-Hub-Signature-256 считается по исходным байтам тела запроса. Поэтому пример сначала проверяет подпись и только затем разбирает JSON: повторное кодирование JSON до вычисления HMAC изменит байты и даст другую подпись.

Что понадобится

  • Учётная запись DevProxy, программа на компьютере и ключ доступа с готовым вариантом «Сайт или API». Начало работы описано в статье «Как начать пользоваться DevProxy».
  • Python 3 и терминал PowerShell, Terminal или оболочка bash.
  • Тестовый репозиторий GitHub, в котором у вас есть права администратора. Не используйте репозиторий с рабочими данными.
  • Отдельный случайный secret для webhook. Ключ доступа DevProxy и webhook secret — разные секреты; не вставляйте один вместо другого.

Условие для туннеля. Используйте отдельный тестовый HTTP-туннель webhook и отключите у него парольный экран браузерного доступа. Если этот экран включён, GitHub не сможет пройти его и получит 401; запрос не дойдёт до обработчика. Не отключайте защиту основного сайта и не рассылайте пароль — настройте только отдельный тестовый туннель.

Шаг 1. Запустите локальный приёмник

Создайте файл webhook_demo.py и вставьте в него весь код:

#!/usr/bin/env python3
"""Local GitHub webhook receiver."""

from __future__ import annotations

import hashlib
import hmac
import json
import os
import re
import socket
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

HOST = "127.0.0.1"
PORT = 8765
MAX_BODY_BYTES = 1024 * 1024
READ_TIMEOUT_SECONDS = 5
SIGNATURE_PATTERN = re.compile(r"sha256=[0-9a-fA-F]{64}")

secret = os.environ.get("WEBHOOK_SECRET")
if not secret:
    raise SystemExit("Set WEBHOOK_SECRET before starting.")
SECRET_BYTES = secret.encode("utf-8")


class WebhookHandler(BaseHTTPRequestHandler):
    timeout = READ_TIMEOUT_SECONDS

    def log_message(self, format: str, *args: object) -> None:
        # Request lines can contain user data; print only the verified event below.
        return

    def reply(self, status: int, text: str = "") -> None:
        self.send_response(status)
        if status != 204:
            body = text.encode("utf-8")
            self.send_header("Content-Type", "text/plain; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        else:
            self.end_headers()

    def do_GET(self) -> None:
        if self.path == "/healthz":
            self.reply(200, "ok")
        else:
            self.reply(404, "not found")

    def do_POST(self) -> None:
        if self.path != "/webhook":
            self.reply(404, "not found")
            return

        length_text = self.headers.get("Content-Length")
        try:
            content_length = int(length_text or "")
        except ValueError:
            self.reply(411, "Content-Length required")
            return
        if content_length < 0 or content_length > MAX_BODY_BYTES:
            self.reply(413, "payload too large")
            return

        signature_header = self.headers.get("X-Hub-Signature-256", "")
        # Validate the header before encoding it: arbitrary Unicode must not reach compare_digest.
        if not SIGNATURE_PATTERN.fullmatch(signature_header):
            self.reply(401, "invalid signature")
            return

        try:
            self.connection.settimeout(READ_TIMEOUT_SECONDS)
            body = self.rfile.read(content_length)
        except (TimeoutError, socket.timeout):
            self.reply(408, "request timeout")
            return
        if len(body) != content_length:
            self.reply(400, "incomplete request body")
            return

        expected_signature = b"sha256=" + hmac.new(
            SECRET_BYTES, body, hashlib.sha256
        ).hexdigest().encode("ascii")
        received_signature = signature_header.encode("ascii")
        if not hmac.compare_digest(expected_signature, received_signature):
            self.reply(401, "invalid signature")
            return

        try:
            payload = json.loads(body)
        except (UnicodeDecodeError, json.JSONDecodeError):
            self.reply(400, "invalid JSON")
            return
        if not isinstance(payload, dict):
            self.reply(400, "JSON object required")
            return

        event = self.headers.get("X-GitHub-Event", "unknown")
        print(f"verified event={event}", flush=True)
        self.reply(204)


if __name__ == "__main__":
    server = ThreadingHTTPServer((HOST, PORT), WebhookHandler)
    print(f"Listening on http://{HOST}:{PORT}", flush=True)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()

Сгенерируйте случайный secret. В PowerShell:

python -c "import secrets; print(secrets.token_urlsafe(32))"

В bash, macOS или Linux:

python3 -c "import secrets; print(secrets.token_urlsafe(32))"

Скопируйте напечатанное значение: оно понадобится и локальному процессу, и форме GitHub. Затем задайте переменную окружения с этим значением и запустите приёмник.

PowerShell:

$env:WEBHOOK_SECRET = "ВСТАВЬТЕ_СГЕНЕРИРОВАННЫЙ_SECRET"
python .\webhook_demo.py

bash:

export WEBHOOK_SECRET='ВСТАВЬТЕ_СГЕНЕРИРОВАННЫЙ_SECRET'
python3 webhook_demo.py

Терминал напечатает Listening on http://127.0.0.1:8765. Если переменная WEBHOOK_SECRET не задана, программа завершится с сообщением об этом. Значения ВСТАВЬТЕ_СГЕНЕРИРОВАННЫЙ_SECRET — пользовательские значения, а не готовый секрет.

Приёмник принимает POST только по пути /webhook, читает не больше 1 МиБ, завершает ожидание тела по тайм-ауту и отвечает 204 после проверки подписи и JSON. Он выводит только тип события из X-GitHub-Event, но не печатает тело запроса, остальные заголовки или секрет.

Шаг 2. Проверьте обработчик без GitHub

В первом окне оставьте приёмник запущенным, во втором проверьте локальный адрес:

curl http://127.0.0.1:8765/healthz

Ответ ok означает, что процесс слушает нужный порт.

Создайте второй файл send_test.py и вставьте этот компактный отправитель:

import hashlib
import hmac
import json
import os
from urllib.request import Request, urlopen

secret = os.environ["WEBHOOK_SECRET"].encode("utf-8")
body = json.dumps({"zen": "test", "hook_id": 1}, separators=(",", ":")).encode("utf-8")
signature = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
request = Request(
    "http://127.0.0.1:8765/webhook",
    data=body,
    method="POST",
    headers={
        "Content-Type": "application/json",
        "X-GitHub-Event": "ping",
        "X-Hub-Signature-256": signature,
        "User-Agent": "local-webhook-test/1.0",
    },
)
with urlopen(request, timeout=10) as response:
    print(response.status, response.read().decode("utf-8"))

В том же втором окне задайте тот же secret и запустите отправитель.

PowerShell:

$env:WEBHOOK_SECRET = "ВСТАВЬТЕ_СГЕНЕРИРОВАННЫЙ_SECRET"
python .\send_test.py

bash:

export WEBHOOK_SECRET='ВСТАВЬТЕ_СГЕНЕРИРОВАННЫЙ_SECRET'
python3 send_test.py

Ожидаемый результат — 204 в терминале отправителя и строка verified event=ping у приёмника. Этот опыт подтверждает только локальную обработку запроса. Он ещё не доказывает доставку из GitHub через туннель.

Шаг 3. Поднимите отдельный HTTPS-туннель

Добавьте в devproxy.yml туннель с именем webhook. Ниже показан минимальный файл для этой задачи; если в вашем файле уже есть другие туннели, сохраните их и добавьте только новый элемент списка.

server: https://control.devtunnels.ru
token: ВАШ_КЛЮЧ
reconnect: true
tunnels:
  - name: webhook
    kind: http
    local: http://127.0.0.1:8765

Замените ВАШ_КЛЮЧ ключом DevProxy и запустите:

devproxy start

Откройте в кабинете раздел «Туннели» и скопируйте фактический текущий адрес туннеля webhook. Добавьте к нему путь /webhook. Например, итоговый Payload URL будет выглядеть как https://webhook.ваше-пространство.devtunnels.ru/webhook; этот пример не заменяет адрес из кабинета.

Пример расположения адреса туннеля в кабинете DevProxy
Пример расположения адреса туннеля в кабинете DevProxy

На снимке показано, где в кабинете расположен адрес туннеля. Это не скриншот доставки webhook. Адрес действует при текущем запущенном туннеле и текущих настройках; после остановки или изменения настроек он может измениться.

Шаг 4. Добавьте webhook в тестовый репозиторий

  1. Откройте тестовый репозиторий, затем Settings → Webhooks → Add webhook. Для этого нужны права администратора.
  2. В поле Payload URL вставьте адрес из кабинета с путём /webhook.
  3. Для Content type выберите application/json.
  4. В поле Secret вставьте тот же случайный secret, который задан в WEBHOOK_SECRET.
  5. Оставьте включённой проверку SSL verification.
  6. Выберите Let me select individual events и отметьте только событие, которое нужно для теста, например Issues. Для первого опыта достаточно одного события; GitHub отдельно пришлёт служебный ping после создания webhook.
  7. Включите Active и нажмите Add webhook.

После создания GitHub автоматически отправит ping. Если вы выбрали Issues, дополнительную доставку можно вызвать, открыв или изменив тестовую задачу.

Шаг 5. Проверьте доставку

Откройте Settings → Webhooks, выберите созданный webhook и прокрутите к Recent Deliveries. У успешной доставки в столбце ответа будет код 2xx. В терминале приёмника должна появиться строка verified event=ping или имя выбранного события.

Если GitHub показывает ошибку, сначала прочитайте код ответа в доставке, затем проверьте тот же адрес в кабинете DevProxy. Для диагностики можно повторить ошибочную доставку штатной кнопкой Redeliver; при этом обработчик создаст новую обработку того же события, поэтому демонстрационному коду не нужны бизнес-действия.

Если что-то не получилось

  • `401` или `403`. Сравните secret в GitHub и WEBHOOK_SECRET без лишних пробелов. Подпись должна проверяться по неизменённым исходным байтам тела и по X-Hub-Signature-256. Если у туннеля включена парольная или cookie-защита браузерного доступа, она может вернуть 401 раньше локального обработчика. Используйте отдельный тестовый туннель без этой защиты; не отключайте защиту основного сайта и не рассылайте пароль.
  • `404`. Проверьте, что в конце публичного адреса есть /webhook, туннель смотрит на http://127.0.0.1:8765, а devproxy start всё ещё выполняется.
  • `400` или `413`. Для JSON используйте application/json; размер тела должен быть не больше 1 МиБ. 400 также появляется для неверного JSON.
  • `408`. Клиент не передал тело вовремя. Для учебной проверки достаточно снова запустить тест или повторить доставку GitHub.
  • Ответ `502`, `504` или соединение не установлено. Проверьте, что приёмник слушает 127.0.0.1:8765, туннель отмечен как работающий и ведёт на этот порт.
  • GitHub не показывает доставку. Проверьте, что webhook Active и что выбранное событие действительно произошло в тестовом репозитории. Для создания webhook ping отправляется автоматически.

Ограничения примера

  • Это приёмник для обучения. Он не сохраняет X-GitHub-Delivery и не исполняет бизнес-действия.
  • Неуспешные доставки GitHub автоматически не повторяет: используйте Redeliver. При повторе идентификатор X-GitHub-Delivery сохраняется; реальный обработчик должен распознавать повторы и обрабатывать событие идемпотентно.
  • Возвращайте 2xx быстро. Долгую работу лучше передать очереди или отдельному процессу.
  • Локальный отправитель проверяет приёмник отдельно. Настоящая доставка из GitHub через установленный туннель в этой статье ещё не испытана; первый реальный результат покажет только отчёт Recent Deliveries.

Источники

Попробуйте бесплатно

Бесплатного тарифа хватает на все сценарии из инструкций.

Создать аккаунт

Другие инструкции