Skip to content

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 в коде нет; усечение появится только вместе с политикой хранения журнала, и тогда окно ленты станет окном воспроизведения.

Решение ​

  1. Источник — существующая лента. Отдельный outbox или журнал событий не вводится: он дублировал бы arkvory_audit. Доставка — потребитель ленты со своим курсором, как зеркало.

  2. Подписки задаёт оператор файлом 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.

  3. Новая подписка получает только события после своего создания: первый шаг записывает текущий head ленты как курсор, история не доставляется. Подписка, перенацеленная на другой репозиторий, начинает с head нового. Подписка, исчезнувшая из файла, забывается: worker при запуске удаляет её строку состояния, поэтому у удалённой подписки не остаётся метрики и оповещения; добавленная позже начнёт с head.

  4. Состояние доставки в БД — таблица 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. Таблица входит в резервную копию и восстанавливается вместе с лентой, чей курсор хранит.

  5. Доставка — цикл в существующем 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).

  6. Тело — JSON {id, repository, sequence, action, artifactId, detail}: поля ленты плюс репозиторий. Без байтов, автора события, секретов и пользовательских метаданных. Формат ленты выбран вместо envelope из API_EVOLUTION: у ленты нет времени события, а выдумывать occurredAt нельзя.

  7. Подпись. Заголовки 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.

  8. Повторы. Пауза после неудачи: 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 в «иногда никогда» без ведома оператора.

  9. Защита от SSRF. Имя разрешается до соединения, проверяются все записи ответа; одна запрещённая запись отклоняет доставку (смешанный ответ не позволяет выбрать внутренний адрес). Соединение идёт на проверенный IP, а не на имя, поэтому смена DNS между проверкой и соединением ничего не даёт. По умолчанию запрещены: loopback, link-local (в том числе 169.254.169.254), private, shared 100.64.0.0/10, документационные, multicast, зарезервированные диапазоны, устаревший site-local fec0::/10 и формы IPv6, несущие IPv4 (IPv4-compatible ::/96, mapped, NAT64 64:ff9b::/96 и 64:ff9b:1::/48, 6to4, Teredo). Private-сети включает оператор списком CIDR ARKVORY_WEBHOOKS_ALLOW_PRIVATE (до 32). HTTP на loopback-имя — единственное исключение. Редиректы не выполняются (3xx — неудача). Проверка сертификата всегда; частный УЦ получателя — ARKVORY_WEBHOOKS_CA_FILE (PEM; добавляется к системным). Таймаут доставки 10 с — общий бюджет: разрешение имени, соединение, отправка и заголовки ответа; запрос получает только остаток после разрешения. Системный резолвер (getaddrinfo, учитывает hosts) не отменяется: ответ после срока отбрасывается (timeout), поток завершается в пределах таймаутов resolv.conf. Ответ читается не более 4 KiB и не сохраняется и не логируется.

  10. Наблюдаемость. Метрики 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, адрес получателя, секрет, тело и текст исключений.

  11. Установка: 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 завершает ротацию. Получателю команда ничего не отправляет. Сети и УЦ действуют, пока не удалена последняя подписка.

  12. Не входит: события identity, UI и API управления подписками, SSE и long-poll, доставка бинарных данных.

Принятые решения по вопросам черновика ​

  1. Подписки — файлом оператора; API с шифрованным секретом в БД отложен до решения о хранилище секретов.
  2. Private-сети — только явным списком CIDR ARKVORY_WEBHOOKS_ALLOW_PRIVATE.
  3. Лимиты подтверждены: 16 подписок, таймаут 10 с, ответ до 4 KiB, окно подписи 5 минут. Добавлен ARKVORY_WEBHOOKS_CA_FILE для получателей с корпоративным УЦ.
  4. Формат тела — поля ленты.

Альтернативы ​

  • Только опрос ленты. Остаётся поддерживаемым способом; вебхуки убирают цикл у каждого потребителя и задержку.
  • 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).

Arkvory · ProAnimaStudio