TunnelHunt

GitHub webhooks на localhost: подпись SHA-256, ping и повтор доставки

5 мин чтения

Принимаем GitHub webhook локально в Flask: X-Hub-Signature-256, HTTPS-туннель, события ping/push, Recent deliveries и проверка повторов.

GitHub доставляет подписанное событие через HTTPS-туннель в локальный обработчик

GitHub может отправлять события репозитория прямо на ваш компьютер. Например, вы разрабатываете интеграцию, которая реагирует на push, и хотите проверить обработчик до деплоя. Для этого нужны доступный HTTPS endpoint и проверка подписи каждого запроса.

В этой инструкции Flask принимает ping и push, проверяет X-Hub-Signature-256 и возвращает диагностический ответ. Пример не запускает сборки и не выполняет команды из webhook. Сначала протестируем его искусственным событием, затем подключим собственный тестовый репозиторий.

1. Создайте локальный обработчик

Нужны Python 3.10+, SSH-клиент и права на создание webhook в отдельном тестовом репозитории. Настройте окружение:

python3 -m venv .venv
source .venv/bin/activate
python -m pip install 'Flask>=3.1,<4'
export GITHUB_WEBHOOK_SECRET="$(python -c 'import secrets; print(secrets.token_hex(32))')"

В PowerShell используйте .venv\Scripts\Activate.ps1 и $env:GITHUB_WEBHOOK_SECRET. Secret для webhook вы генерируете сами; personal access token GitHub для приёма webhook не требуется. Сохраните одно и то же значение для запуска обработчика и настройки репозитория.

Создайте app.py:

import hashlib
import hmac
import os
import re
import uuid

from flask import Flask, request

app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024
SECRET = os.environ['GITHUB_WEBHOOK_SECRET']
if not SECRET:
    raise ValueError('GITHUB_WEBHOOK_SECRET must not be empty')


def verify_signature(raw_body, signature, secret):
    if not isinstance(signature, str) or not re.fullmatch(r'sha256=[0-9a-f]{64}', signature):
        return False
    expected = 'sha256=' + hmac.new(
        secret.encode('utf-8'), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.post('/webhook/github')
def webhook():
    raw_body = request.get_data()
    if not verify_signature(raw_body, request.headers.get('X-Hub-Signature-256'), SECRET):
        return 'Invalid signature', 403
    if request.mimetype != 'application/json':
        return 'Expected application/json', 415
    payload = request.get_json(silent=True)
    if not isinstance(payload, dict):
        return 'Expected a JSON object', 400
    delivery = request.headers.get('X-GitHub-Delivery', '')
    try:
        uuid.UUID(delivery)
    except ValueError:
        return 'Invalid delivery ID', 400
    event = request.headers.get('X-GitHub-Event', '')
    if event == 'ping':
        if not isinstance(payload.get('hook_id'), int):
            return 'Invalid ping payload', 400
    elif event == 'push':
        if (not isinstance(payload.get('ref'), str)
                or not isinstance(payload.get('repository'), dict)):
            return 'Invalid push payload', 400
    else:
        return {'ignored': True}, 202
    # Учебный endpoint: без запуска команд и изменения данных репозитория.
    return {'received': event, 'delivery': delivery}, 200


if __name__ == '__main__':
    app.run(host='127.0.0.1', port=8000, debug=False)

Подпись вычисляется от исходных байтов тела, а не от повторно сериализованного JSON. Пробелы и UTF-8 влияют на HMAC. Проверка через compare_digest и SHA-256 описана в официальной инструкции GitHub. Учебный сервер ограничивает тело одним мегабайтом; для больших push-событий потребуется отдельная политика размера и обработки.

Запустите python app.py.

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

В другом терминале с тем же GITHUB_WEBHOOK_SECRET задайте тестовое тело и подпись:

export GITHUB_BODY='{"hook_id":12345,"zen":"local test"}'
SIGNATURE=$(python -c 'import os,hmac,hashlib; print("sha256="+hmac.new(os.environ["GITHUB_WEBHOOK_SECRET"].encode(), os.environ["GITHUB_BODY"].encode(), hashlib.sha256).hexdigest())')
curl -i http://127.0.0.1:8000/webhook/github \
  -H 'Content-Type: application/json' \
  -H 'X-GitHub-Event: ping' \
  -H 'X-GitHub-Delivery: 11111111-2222-4333-8444-555555555555' \
  -H "X-Hub-Signature-256: ${SIGNATURE}" \
  --data-raw "$GITHUB_BODY"

Ожидаются HTTP 200, received: ping и тот же delivery ID. Удалите подпись — получите 403. Измените тело, сохранив прежнюю подпись, — также 403. Это локальная проверка валидации; она не подтверждает настройку webhook в репозитории.

3. Создайте HTTPS-адрес

Для Free добавьте публичный SSH-ключ. Затем в новом терминале:

ssh -p 2222 -o ExitOnForwardFailure=yes \
  -R 80:127.0.0.1:8000 tunnelhunt.ru

Возьмите выданный HTTPS-origin и повторите предыдущий запрос через https://YOUR-SUBDOMAIN.tunnelhunt.ru/webhook/github. Ожидается тот же ответ. Если локально работает, а снаружи нет, проверьте порт и соединение по инструкции localhost.

4. Настройте webhook тестового репозитория

В Settings → Webhooks своего тестового репозитория добавьте webhook:

  • Payload URL: полный HTTPS URL /webhook/github из предыдущего шага.
  • Content type: application/json.
  • Secret: то же значение, с которым запущен Flask.
  • Events: для этого примера достаточно push.
  • SSL verification: включена; webhook активен.

Создание webhook требует подходящих прав; шаги описаны в документации GitHub. После сохранения GitHub отправляет ping. В Recent deliveries откройте событие и проверьте ответ своего обработчика. Затем сделайте тестовый push в собственный репозиторий и найдите доставку push с HTTP 200 и received: push.

Не передавайте содержимое webhook в shell или eval: подписанный запрос всё равно содержит данные коммитов и пользователей, которые нельзя исполнять как код. Для реальной интеграции проверяйте ожидаемый репозиторий, доступы и поля конкретного события.

Повтор: GitHub Redeliver и TunnelHunt Replay

GitHub позволяет повторно отправить доставку из интерфейса репозитория. В TunnelHunt можно найти входящий запрос в Logs, изменить локальный код и нажать Replay. Пока секрет тот же и тело сохранено, подпись должна пройти снова. После смены секрета старые подписанные доставки могут перестать проходить проверку.

Повтор с прежним X-GitHub-Delivery не должен создавать вторую бизнес-операцию. Для рабочего обработчика храните обработанные delivery ID и результат атомарно в БД или надёжно ставьте событие в очередь, прежде чем подтвердить приём. Ошибка после постановки в очередь не должна запускать работу дважды. Учебный пример ничего не меняет, поэтому повтор просто возвращает диагностический ответ.

HMAC защищает тело запроса; заголовки event и delivery не входят в вычисляемую подпись. Сопоставляйте тип события со схемой payload и своими правилами, а не считайте эти заголовки отдельной авторизацией. Ограничения и обработка доставок описаны в рекомендациях GitHub.

Диагностика

Симптом Что проверить
403 Один secret у GitHub и Flask, заголовок X-Hub-Signature-256, исходные байты тела
415 Content type в настройках webhook — application/json
400 JSON-объект, delivery ID и поля нужного события
202 Пришло событие, которое этот пример намеренно не обрабатывает
413 Тело больше учебного лимита 1 МБ
502 Flask и SSH работают, локальный порт совпадает
События не появляются Webhook активен, выбрано нужное событие, проверьте Recent deliveries
URL работал до переподключения Случайный поддомен изменился; обновите Payload URL или используйте резервирование

Для повторных сессий разработки закрепите имя на Pro и подключайтесь привязанным SSH-ключом. Для восстановления соединения есть autossh, но компьютер и приложение должны оставаться включёнными. После теста отключите webhook в тестовом репозитории и остановите SSH.

Получите GitHub webhook на localhost

Проверьте искусственный ping локально и по HTTPS, затем подключите отдельный тестовый репозиторий. Постоянный поддомен Pro поможет сохранить Payload URL при новых SSH-сессиях.

Первый запуск: добавьте публичный SSH-ключ, запустите локальное приложение и команду SSH из статьи, затем откройте выданный HTTPS-адрес. Если ответа нет, пройдите проверку localhost и туннеля.

Free: 1 ГБ трафика в месяц, 1 туннель, сессия до 4 часов. Pro: 199 ₽/месяц, 50 ГБ, до 5 туннелей и резервирование поддомена. Компьютер, приложение и SSH-соединение должны работать; постоянный адрес сохраняет имя, а не доступность выключенного сервера.

Готовы запустить свой первый туннель?

Начните использовать TunnelHunt бесплатно уже сегодня и откройте доступ к локальному серверу всего одной командой.

Зарегистрироваться бесплатно →

По теме