0069. Вебхуки поверх ленты изменений
Статус: accepted. Owner: Ian Panaev. Дата: 2026-10-07. Решение добавляет исходящую границу доверия (AGENTS.md, «Обязательные границы»; SECURITY.md: защита от SSRF обязательна). Владелец поручил реализовать вебхуки с предложенными значениями («на прод уровне»); открытые вопросы черновика закрыты ниже.
Контекст
CI/CD, агенты развёртывания и игровые студии узнают о новой сборке опросом. План этапа D (API_EVOLUTION, API_MAP) описывает R/events, R/webhooks, transactional outbox, повторную доставку и подпись.
Факты кода, на которые опирается решение:
GET /api/v1/repositories/{r}/changes?after=&limit=(listCatalogChanges, правоartifact.list, ADR 0058) уже отдаёт упорядоченную ленту репозитория:{items: [{sequence, action, artifactId, detail}], head, next}, до 100 событий. События пишутся вarkvory_auditпод блокировкой репозитория в той же транзакции, что и изменение каталога, поэтому курсор поsequenceне теряет событий, а порядок внутри репозитория определён. Событие — подсказка «изменилось вот это»; потребитель перечитывает состояние обычными маршрутами чтения.- Первая половина плана («events cursor») этой лентой закрыта. Не закрыто уведомление получателя без опроса.
- Исходящие запросы сервера шли только на адреса из
ARKVORY_MIRRORS_FILEи на фиксированные адреса продукта. Цикл зеркал в worker — образец: курсор, повторы, состояние в БД, метрики. - Секрет подписи сервер обязан знать (токены хранятся хешем). Хранилища секретов, кроме файлов службы, нет.
- Усечения
arkvory_auditв коде нет; усечение появится только вместе с политикой хранения журнала, и тогда окно ленты станет окном воспроизведения.
Решение
Источник — существующая лента. Отдельный outbox или журнал событий не вводится: он дублировал бы
arkvory_audit. Доставка — потребитель ленты со своим курсором, как зеркало.Подписки задаёт оператор файлом
ARKVORY_WEBHOOKS_FILE(JSON{"webhooks": [...]}, какARKVORY_MIRRORS_FILE):{id, repository, url, secretFile, nextSecretFile?, actions?}. API управления подписками и хранение секрета в БД не вводятся: секрет лежит в файле с правами учётной записи службы, как ключ зеркала. До 16 подписок на установку;idиrepository—[a-z0-9][a-z0-9_-]{0,63};url— HTTPS (HTTP только дляlocalhost,127.0.0.1,[::1]), без учётных данных, query и fragment, до 2048 символов;actions— необязательный список действий ленты (artifact.publish,stage.added, …). Неизвестное поле, дубльidили нечитаемый файл останавливают запуск worker.Новая подписка получает только события после своего создания: первый шаг записывает текущий
headленты как курсор, история не доставляется. Подписка, перенацеленная на другой репозиторий, начинает сheadнового. Подписка, исчезнувшая из файла, забывается: worker при запуске удаляет её строку состояния, поэтому у удалённой подписки не остаётся метрики и оповещения; добавленная позже начнёт сhead.Состояние доставки в БД — таблица
arkvory_webhook_state(миграция 33,SCHEMA_VERSION = 33, expand-only):subscription,repository,cursor,failures,error_code,error_at,next_attempt_at,delivered_at,delivered_count. Независимый жизненный цикл, как уarkvory_mirror_state. Таблица входит в резервную копию и восстанавливается вместе с лентой, чей курсор хранит.Доставка — цикл в существующем worker (
apps/worker/src/webhook-loop.ts, сценарийWebhookDeliveryвapplication). Для каждой подписки: читать ленту после курсора пачкой до 100, отправлять события по одному в порядкеsequence, сдвигать курсор только после ответа 2xx. Гарантия — at-least-once; порядок внутри репозитория сохраняется, потому что событие N+1 не уходит, пока N не доставлено. События, не подходящие подactions, пропускаются и сдвигают курсор. Получатель дедуплицирует поid = <repository>:<sequence>. После падения процесса между ответом получателя и сохранением курсора событие повторяется под тем жеid. Курсор монотонен: сохранение состояния с курсором меньше записанного для того же репозитория игнорируется (WHEREв upsert), поэтому worker, потерявший владение хранилищем и дописывающий запрос, не откатывает позицию преемника; перед каждым событием цикл проверяет и остановку, и владение (catalog.active).Тело — JSON
{id, repository, sequence, action, artifactId, detail}: поля ленты плюс репозиторий. Без байтов, автора события, секретов и пользовательских метаданных. Формат ленты выбран вместо envelope из API_EVOLUTION: у ленты нет времени события, а выдумыватьoccurredAtнельзя.Подпись. Заголовки
X-Arkvory-Delivery(то жеid),X-Arkvory-Event(действие),X-Arkvory-Timestamp(UTC, секунды) иX-Arkvory-Signature: sha256=<hex HMAC-SHA256(secret, "<timestamp>.<body>")>. Получатель отвергает метку времени старше 5 минут и сравнивает подписи за постоянное время. Ротация: приnextSecretFileдоставка несёт две подписи через запятую, получатель принимает любую. Секреты читаются из файлов при каждой доставке, поэтому смена файла не требует перезапуска; короче 16 байт секрет отвергается (secret). Эталонная проверка —verifyWebhookвinfrastructure.Повторы. Пауза после неудачи: 12 с, затем удвоение до 1 часа, ±20% случайности (не более 5 неудачных попыток в минуту на подписку). Недоступный получатель не задерживает загрузки, скачивания и другие подписки: цикл подписки изолирован. Причина неудачи — константный код:
blocked,timeout,network,tls,redirect,http_4xx,http_5xx,secret; он хранится вerror_codeи виден в метрике. Сбой самого worker до отправки —unavailable(чтение ленты или состояния из БД,ArkvoryError) иinternal(прочее) — пишется только в журналwebhook.step_failed, в состояние не попадает и повторяется через 5 с без паузы подписки.Получатель, отвергающий одно событие, блокирует подписку. Событие не пропускается никогда: это и есть гарантия at-least-once без потерь. Поэтому постоянный отказ на одном событии (например,
4xxна тело, которое получатель не разбирает) останавливает всю подписку: следующие события не уходят, другие подписки не затронуты. Сигнал:webhook.step_failedсhttp_4xx(или другим кодом) и растущимattempts,arkvory_webhook_failing = 1, оповещениеArkvoryWebhookFailingчерез 15 минут;arkvory_webhook_last_success_timestamp_secondsне растёт. Исправление: (1) исправить получателя — событие уйдёт при следующей попытке (пауза до 1 часа), затем очередь по порядку; повторныйconfigure --webhookс тем жеidи репозиторием сохраняет курсор; (2) если событие принять нельзя никогда —--webhook-detachи повторное подключение: подписка начинает сhead, накопленные события не доставляются, получатель дочитывает их изGET R/changes?after=<последний обработанный sequence>. Автоматический пропуск («dead letter») не вводится: он превращает at-least-once в «иногда никогда» без ведома оператора.Защита от SSRF. Имя разрешается до соединения, проверяются все записи ответа; одна запрещённая запись отклоняет доставку (смешанный ответ не позволяет выбрать внутренний адрес). Соединение идёт на проверенный IP, а не на имя, поэтому смена DNS между проверкой и соединением ничего не даёт. По умолчанию запрещены: loopback, link-local (в том числе
169.254.169.254), private, shared 100.64.0.0/10, документационные, multicast, зарезервированные диапазоны, устаревший site-localfec0::/10и формы IPv6, несущие IPv4 (IPv4-compatible::/96, mapped, NAT6464:ff9b::/96и64:ff9b:1::/48, 6to4, Teredo). Private-сети включает оператор списком CIDRARKVORY_WEBHOOKS_ALLOW_PRIVATE(до 32). HTTP на loopback-имя — единственное исключение. Редиректы не выполняются (3xx — неудача). Проверка сертификата всегда; частный УЦ получателя —ARKVORY_WEBHOOKS_CA_FILE(PEM; добавляется к системным). Таймаут доставки 10 с — общий бюджет: разрешение имени, соединение, отправка и заголовки ответа; запрос получает только остаток после разрешения. Системный резолвер (getaddrinfo, учитывает hosts) не отменяется: ответ после срока отбрасывается (timeout), поток завершается в пределах таймаутовresolv.conf. Ответ читается не более 4 KiB и не сохраняется и не логируется.Наблюдаемость. Метрики
arkvory_webhook_failingиarkvory_webhook_last_success_timestamp_seconds(меткиsubscription,repository; не более 16 пар), правилоArkvoryWebhookFailing, записиwebhook.started,webhook.step_failed(поляsubscription,errorCode,attempts),webhook.recovered,webhook.stopped(конец цикла подписки: остановка или потеря владения хранилищем) иwebhook.prune_failed(при запуске не удалось забыть удалённые подписки; повтор при следующем запуске, их строки и метрики остаются до него). В журнал не попадают URL, адрес получателя, секрет, тело и текст исключений.Установка:
arkvory configure --webhook <id> --webhook-repository <repo> --webhook-url <url> --webhook-secret-file <file> [--webhook-next-secret-file <file>] [--webhook-actions a,b] [--webhook-allow-private <cidr,..>] [--webhook-ca-file <pem>]или--webhook-detach <id>(как--mirror): проверяет ввод до изменений (имя, репозиторий, URL по правилам worker, секрет не короче 16 печатных символов, CIDR, сертификаты), копирует секрет вconfig/webhooks(systemd:root:arkvory 0640; Compose: каталог0755для bind-mount, секрет0600пользователя контейнера uid 1000,webhooks.jsonиca.pem—0644), пишетwebhooks.json,ARKVORY_WEBHOOKS_FILE,ARKVORY_WEBHOOKS_ALLOW_PRIVATEиARKVORY_WEBHOOKS_CA_FILEвruntime.json, для Compose добавляетconfig/compose.webhooks.ymlс монтированием в контейнер worker, перезапускает службы и требует готовности; при сбое возвращает прежние файлы с их прежними режимом и владельцем и перезапускает. Один вызов меняет одну подписку; повтор с тем же именем заменяет её, повтор без--webhook-next-secret-fileзавершает ротацию. Получателю команда ничего не отправляет. Сети и УЦ действуют, пока не удалена последняя подписка.Не входит: события identity, UI и API управления подписками, SSE и long-poll, доставка бинарных данных.
Принятые решения по вопросам черновика
- Подписки — файлом оператора; API с шифрованным секретом в БД отложен до решения о хранилище секретов.
- Private-сети — только явным списком CIDR
ARKVORY_WEBHOOKS_ALLOW_PRIVATE. - Лимиты подтверждены: 16 подписок, таймаут 10 с, ответ до 4 KiB, окно подписи 5 минут. Добавлен
ARKVORY_WEBHOOKS_CA_FILEдля получателей с корпоративным УЦ. - Формат тела — поля ленты.
Альтернативы
- Только опрос ленты. Остаётся поддерживаемым способом; вебхуки убирают цикл у каждого потребителя и задержку.
- API
R/webhooksиз плана D. Секрет пришлось бы хранить в БД зашифрованным: нужен ключ шифрования и выбранный secret store. Откладывается. - Отдельный outbox. Дублирует
arkvory_auditи добавляет вторую точку расхождения; без измеренной нужды не вводится. - SSE или long-poll. Не требует исходящих соединений, но держит соединение на каждого получателя; возможен следующим шагом.
Последствия
- Новая граница доверия: сервер отправляет запросы по адресам из файла оператора. Риск ограничен HTTPS с проверкой, политикой исходящих адресов и тем, что адрес задаёт только администратор.
- Права подписки — оператора, а не пользователя: подписка видит поля ленты своего репозитория. Отзыв доступа отдельного пользователя на неё не действует; документация говорит об этом.
- Стоимость: один цикл в worker и одна небольшая таблица; нагрузка на загрузки нулевая. Перед запуском нового worker все процессы обновлены до схемы 33.
- Файлы можно по-прежнему написать вручную (
ARKVORY_WEBHOOKS_FILEвconfig/runtime.json), но для Compose нужен bind-mount в контейнер worker: его делает командаconfigure --webhook. - Миграция аддитивна; без
ARKVORY_WEBHOOKS_FILEповедение не меняется.
Проверка
- Unit: конфигурация (
tests/webhook-config.test.mjs), политика адресов и разрешение имён (webhook-egress), подпись и проверка со сроком и ротацией (webhook-signature), сценарий доставки на подставных портах: порядок, курсор после 2xx, пауза, повтор под тем жеid, фильтр, страница из 100, отмена (webhook-delivery), отправитель на настоящих HTTP/HTTPS-серверах: заголовки, подпись, коды ответов, редиректы, таймаут, лимит ответа, смена DNS, проверка сертификата (webhook-sender). - Интеграция с реальным PostgreSQL: публикации в порядке журнала, недоступный получатель ничего не теряет и не задерживает загрузку, метрика, состояние с 64-битным курсором (
tests/integration/webhooks.test.mjs). - Команда установки (
tests/webhook-setup.test.mjs): запись файлов и переменных, замена и удаление, ротация, Compose-override только для worker, сети и УЦ, отказ при неверном вводе до любых изменений, 16 подписок, откат при сбое готовности. - Процессы: настоящий worker забывает удалённые подписки, доставляет, после перезапуска не повторяет доставленное, не пишет в журнал секрет и адрес, повторяет после отказа, не стартует с неверным файлом (
tests/integration/webhook-processes.test.mjs). - Тест правил оповещения проверяет, что
ArkvoryWebhookFailingссылается на существующую метрику. Застрявшая подписка (пункт 8) держитerror_codeи тем самымarkvory_webhook_failing = 1, поэтому отдельное правило не нужно. - Регрессии ревью: диапазоны
::/96,64:ff9b:1::/48,fec0::/10и бюджет разрешения имени (webhook-egress,webhook-sender), права и владелец файлов при откате и секреты Compose (webhook-setup), монотонный курсор (tests/integration/webhook-state.test.mjs).