Как проверить 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.pybash:
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.pybash:
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; этот пример не заменяет адрес из кабинета.

На снимке показано, где в кабинете расположен адрес туннеля. Это не скриншот доставки webhook. Адрес действует при текущем запущенном туннеле и текущих настройках; после остановки или изменения настроек он может измениться.
Шаг 4. Добавьте webhook в тестовый репозиторий
- Откройте тестовый репозиторий, затем
Settings → Webhooks → Add webhook. Для этого нужны права администратора. - В поле
Payload URLвставьте адрес из кабинета с путём/webhook. - Для
Content typeвыберитеapplication/json. - В поле
Secretвставьте тот же случайный secret, который задан вWEBHOOK_SECRET. - Оставьте включённой проверку
SSL verification. - Выберите
Let me select individual eventsи отметьте только событие, которое нужно для теста, напримерIssues. Для первого опыта достаточно одного события; GitHub отдельно пришлёт служебныйpingпосле создания webhook. - Включите
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и что выбранное событие действительно произошло в тестовом репозитории. Для создания webhookpingотправляется автоматически.
Ограничения примера
- Это приёмник для обучения. Он не сохраняет
X-GitHub-Deliveryи не исполняет бизнес-действия. - Неуспешные доставки GitHub автоматически не повторяет: используйте
Redeliver. При повторе идентификаторX-GitHub-Deliveryсохраняется; реальный обработчик должен распознавать повторы и обрабатывать событие идемпотентно. - Возвращайте
2xxбыстро. Долгую работу лучше передать очереди или отдельному процессу. - Локальный отправитель проверяет приёмник отдельно. Настоящая доставка из GitHub через установленный туннель в этой статье ещё не испытана; первый реальный результат покажет только отчёт
Recent Deliveries.
Источники
- GitHub Docs: Creating webhooks, Validating webhook deliveries, Testing webhooks, Best practices, Redelivering webhooks.
- Python: http.server, hmac, urllib.request.
Попробуйте бесплатно
Бесплатного тарифа хватает на все сценарии из инструкций.
Другие инструкции
- Как начать пользоваться DevProxy: регистрация, ключ и установка программы
- Как показать заказчику сайт, который открыт только на вашем компьютере
- Как подключиться к рабочему компьютеру из дома без белого IP и VPN
- Как открыть умный дом Home Assistant с телефона вне дома без проброса портов
- Как смотреть свою домашнюю медиатеку Jellyfin вне дома
- Как пользоваться нейросетью на домашнем компьютере со своего телефона
- Как слушать музыку с домашнего компьютера как своё интернет-радио
- Как дать доступ к папке на компьютере по ссылке
- DevProxy вместо ngrok: туннели для работы и дома