feat(notify): HMAC-signed outgoing webhooks with per-tier secrets and test sender
Build / build (push) Successful in 10m36s
Build / build (push) Successful in 10m36s
Outgoing notifications were bare POSTs with no auth and no way to verify
they came from Tinyforge. They also went out from one global URL only,
even though stages had a notification_url field, and static-site sync
emitted no events at all.
Schema: add notification_url + notification_secret (lazy-generated) to
settings, projects, stages and static_sites. Migrations are additive.
Notifier: SendSigned computes HMAC-SHA256 over the exact body bytes and
sends X-Hub-Signature-256 (GitHub-compatible — receivers built for
GitHub/Gitea/Forgejo verify out of the box). Aux headers
X-Tinyforge-Event/Delivery/Timestamp/Tier are advisory and not signed.
Empty secret => unsigned send for back-compat.
Resolution: deploys fall through stage > project > settings, sites fall
through site > settings. The secret travels with the URL that sourced
it, so any tier can sign even when its parents are unsigned. Site sync
events now actually emit (site_sync_success / site_sync_failure).
API: 12 new endpoints — {GET secret, POST regenerate, POST disable,
POST test} for each of the 4 tiers. SendSyncForTest returns
status_code/latency_ms/signature_sent/delivery_id/response_snippet so
the UI surfaces receiver feedback inline.
UI: shared OutgoingWebhookPanel.svelte fits the existing card aesthetic.
Signing-state pill, secret reveal-on-demand, regenerate/disable behind
ConfirmDialog modals (not inline strips — too easy to misclick), send-
test result card with colour-coded status. Wired into Settings →
Integrations, project edit form, per-stage edit, and per-site detail.
EN + RU i18n.
Tests: round-trip (sender signs, receiver verifies), tampered-body and
wrong-secret rejection, unsigned-send omits header, send-test surfaces
4xx, concurrent fan-out via Drain. Resolver precedence locked for both
deploy and site paths.
Docs: docs/webhooks.md with header reference, verifier snippets in
Node/Python/Go, and a recipe for the service-to-notification-bridge
generic webhook provider.
This commit is contained in:
@@ -120,6 +120,16 @@
|
||||
"projectDetail": {
|
||||
"webhookTitle": "Webhook проекта",
|
||||
"webhookDesc": "Отправьте POST с image-ссылкой на этот URL из CI — и Tinyforge запустит деплой. Стейдж выбирается по tag_pattern.",
|
||||
"outgoingWebhookTitle": "Исходящий webhook (проект)",
|
||||
"outgoingWebhookDesc": "Куда Tinyforge отправляет события деплоя для этого проекта. Стейджи могут переопределить; если нигде не задано — используется глобальная настройка.",
|
||||
"outgoingFallbackGlobal": "глобальной настройки интеграций",
|
||||
"notificationUrlLabel": "URL исходящего webhook",
|
||||
"notificationUrlHelp": "Оставьте пустым для наследования из глобальных настроек. Стейджи могут переопределить.",
|
||||
"stageNotificationUrlLabel": "URL исходящего webhook (этот стейдж)",
|
||||
"stageNotificationUrlHelp": "Оставьте пустым для наследования от проекта, затем — из глобальных настроек.",
|
||||
"stageOutgoingTitle": "Исходящий webhook (стейдж)",
|
||||
"stageOutgoingDesc": "Куда Tinyforge отправляет события деплоя этого стейджа. Побеждает самый конкретный уровень.",
|
||||
"stageFallbackLabel": "проектной или глобальной настройки",
|
||||
"deleteProject": "Удалить проект",
|
||||
"envVars": "Переменные окружения",
|
||||
"volumes": "Тома",
|
||||
@@ -618,6 +628,11 @@
|
||||
"sites": {
|
||||
"webhookTitle": "Webhook сайта",
|
||||
"webhookDesc": "Укажите этот URL в push-вебхуке Git-провайдера. Tinyforge пересинхронизирует сайт при подходящей ref-ссылке (ветка для push, шаблон тега для tag). Пустое тело запускает синхронизацию безусловно.",
|
||||
"outgoingUrlTitle": "URL исходящего webhook (этот сайт)",
|
||||
"outgoingUrlDesc": "Куда Tinyforge отправляет события site_sync_success / site_sync_failure. Пусто — наследовать из глобальных настроек.",
|
||||
"outgoingWebhookTitle": "Исходящий webhook (сайт)",
|
||||
"outgoingWebhookDesc": "HMAC-секрет и тестовая отправка для разрешённого исходящего URL.",
|
||||
"outgoingFallbackGlobal": "глобальной настройки интеграций",
|
||||
"title": "Статические сайты",
|
||||
"addSite": "Новый сайт",
|
||||
"newSite": "Новый статический сайт",
|
||||
@@ -1168,6 +1183,43 @@
|
||||
"confirmYes": "Перегенерировать",
|
||||
"confirmNo": "Отмена"
|
||||
},
|
||||
"outgoingWebhook": {
|
||||
"signingOn": "Подпись включена",
|
||||
"signingOff": "Без подписи",
|
||||
"signingSecret": "HMAC-секрет",
|
||||
"noSecret": "Секрет не задан — исходящие события отправляются без подписи.",
|
||||
"reveal": "Показать",
|
||||
"generate": "Сгенерировать",
|
||||
"copy": "Копировать",
|
||||
"copied": "Секрет скопирован в буфер обмена",
|
||||
"copyFailed": "Не удалось скопировать",
|
||||
"loadFailed": "Не удалось загрузить секрет",
|
||||
"regenerate": "Перегенерировать",
|
||||
"regenerated": "Секрет перегенерирован",
|
||||
"regenerateFailed": "Не удалось перегенерировать секрет",
|
||||
"confirmRegenerateTitle": "Перегенерировать секрет?",
|
||||
"confirmRegenerate": "Текущий секрет инвалидируется немедленно. Все получатели должны быть обновлены синхронно — иначе начнут отклонять события.",
|
||||
"confirmDisableTitle": "Отключить HMAC-подпись?",
|
||||
"confirmDisable": "Будущие события пойдут без заголовка X-Hub-Signature-256. Получатели, требующие подпись, начнут их отклонять.",
|
||||
"confirmYes": "Подтвердить",
|
||||
"confirmNo": "Отмена",
|
||||
"disable": "Отключить подпись",
|
||||
"disabled": "Подпись отключена",
|
||||
"disableFailed": "Не удалось отключить подпись",
|
||||
"sendTestTitle": "Отправить тестовое событие",
|
||||
"sendTestHelp": "Отправляет синтетическое событие \"test\" на разрешённый URL с текущим секретом.",
|
||||
"sendTest": "Отправить тест",
|
||||
"sending": "Отправка…",
|
||||
"testFailed": "Не удалось отправить тестовое событие",
|
||||
"tier": "Уровень",
|
||||
"signed": "Подписано",
|
||||
"unsigned": "Без подписи",
|
||||
"deliveryId": "Доставка",
|
||||
"responseBody": "Тело ответа",
|
||||
"networkError": "Сетевая ошибка",
|
||||
"fallbackTo": "URL не задан на этом уровне — события унаследуются от {label}.",
|
||||
"noUrlConfigured": "URL не задан. Настройте его выше перед тестом."
|
||||
},
|
||||
"settingsMaintenance": {
|
||||
"title": "Обслуживание",
|
||||
"thresholds": "Пороги",
|
||||
|
||||
Reference in New Issue
Block a user