Симптом: vpnbot-xray-release-alert.service падал на каждой попытке доставки (раз в RETRY_SECONDS=900): 20 с таймаута на заблокированном 149.154.166.110:443, затем ENETUNREACH на IPv6. Ни авария, ни сообщение о восстановлении не доходили. Первопричина: монитор слал sendMessage прямым urllib.urlopen на api.telegram.org, в обход сетевого контракта, которым пользуются все процессы бота: VPNBOT_TELEGRAM_IP_FAMILY=ipv4, резервные VPNBOT_TELEGRAM_API_FALLBACK_IPV4S и relay-туннели node manager из /run/vpnbot-node-manager/telegram-egress.json. Исправление: контракт читается из того же root-only runtime-env бота, откуда уже берётся токен (без копии в /etc и без импорта кода бота). Маршруты как у бота: свежая проекция со здоровыми портами api.telegram.org - только loopback-relay; иначе резервные IPv4, затем DNS выбранного семейства. Меняется лишь TCP-адрес, TLS держит SNI и проверку сертификата api.telegram.org. Инвариант: следующий маршрут пробуется только если соединение не установилось и запрос не ушёл; ушедший запрос без ответа - telegram_delivery_ambiguous без повтора в этом запуске. Каждый отказ несёт typed-код (telegram_egress_config_invalid, telegram_no_route, telegram_connect_failed, telegram_delivery_ambiguous, telegram_http_status, telegram_response_invalid, telegram_rejected). Проверка: unittest discover - 44 теста, включая TLS-сервер с SNI api.telegram.org за loopback-портом; живой запрос getMe с заведомо неверным токеном через relay 18443/18444 и резервный 149.154.167.220 получил 401 от Telegram. Claude-Session: local_e733a8c5-58d7-482a-bce7-150abdb901db
157 lines
13 KiB
Markdown
157 lines
13 KiB
Markdown
# Telegram-уведомления о безопасно остановившемся выпуске Xray
|
||
|
||
## Задача
|
||
|
||
Автоматический выпуск уже безопасно останавливается до появления `proven`,
|
||
если патчи не накладываются, сборка или тесты падают, candidate слишком долго
|
||
не проходит пилот либо canary не подтверждает активный отзыв доступа. Сейчас
|
||
это состояние видно в Forgejo Actions, systemd и root-only JSON промоутера, но
|
||
оператор должен сам регулярно проверять все три места.
|
||
|
||
Нужен отдельный наблюдатель, который быстро сообщает в Telegram только о
|
||
состояниях, требующих реакции:
|
||
|
||
1. workflow автоматического candidate-выпуска завершился ошибкой или был
|
||
отменён;
|
||
2. опубликованный candidate не получил соответствующий `proven` дольше
|
||
допустимого времени;
|
||
3. production-canary завершился ошибкой, включая отдельную повышенную
|
||
критичность, если не удался и возврат на предыдущий proven.
|
||
|
||
Сам наблюдатель не должен изменять релизы, canary, конфигурацию Xray или парк
|
||
узлов. Его единственная запись во внешнюю систему — операторское сообщение в
|
||
Telegram.
|
||
|
||
## Источники истины
|
||
|
||
- Структурированное состояние Forgejo Actions владеет результатом
|
||
`candidate.yml`. Сначала наблюдатель читает публичный Actions API. Если
|
||
Forgejo показывает запуски в web-интерфейсе, но API возвращает пустой
|
||
`workflow_runs`, используется ограниченный read-only fallback: страница
|
||
именно `candidate.yml` выбирает последний запуск `main`, а detail-страница
|
||
отдаёт встроенный JSON `state.run`. Иконки и локализованный HTML-текст не
|
||
считаются доказательством результата. Успешный запуск закрывает ранее
|
||
зарегистрированный сбой; незавершённый запуск считается `pending` только до
|
||
общего stall-порога, после чего становится проблемой runner.
|
||
- Forgejo Releases API и манифест candidate владеют связью
|
||
`candidate -> proven`. Задержка считается от `published_at`, а не от времени
|
||
локального опроса. Уже опубликованный proven немедленно снимает задержку.
|
||
- `/var/lib/vpnbot-xray-release-promoter/*.json` владеет результатом живого
|
||
пилота. Фазы `failed_rolled_back` и `failed_rollback_failed` являются
|
||
canary-инцидентами. Фазы повторной установки, проверки и продвижения —
|
||
переходные: во время них прежнее уведомление ещё нельзя объявлять
|
||
восстановленным.
|
||
- `/var/lib/vpnbot-xray-release-alert/state.json` владеет только жизненным
|
||
циклом операторских уведомлений: что уже отправлялось, когда была последняя
|
||
попытка и когда состояние действительно восстановилось. Он не становится
|
||
источником истины для самого выпуска.
|
||
|
||
## Архитектура
|
||
|
||
Один root-only systemd timer раз в пять минут запускает один `oneshot`-процесс.
|
||
Процесс берёт неблокирующую файловую блокировку, получает общий снимок Forgejo,
|
||
читает локальные состояния промоутера и вычисляет три независимых условия:
|
||
|
||
- `patch_pipeline`;
|
||
- `candidate_stalled`;
|
||
- `canary_failed`.
|
||
|
||
Для каждого условия возможны три результата:
|
||
|
||
- `problem` — подтверждён новый или продолжающийся инцидент;
|
||
- `healthy` — источник истины подтверждает восстановление;
|
||
- `pending` — идёт новый workflow или повторный пилот, поэтому прежний
|
||
инцидент пока нельзя закрывать.
|
||
|
||
`waiting`, `queued` или другой переходный статус не может оставаться
|
||
безмолвным бесконечно. После общего stall-порога наблюдатель публикует
|
||
операторскую проблему с идентификатором запуска и предлагает проверить
|
||
регистрацию runner, его состояние online и обязательную метку `linux`.
|
||
|
||
У каждого инцидента есть детерминированная сигнатура: идентификатор Actions-
|
||
запуска либо набор candidate-тегов и стадий. Новая сигнатура создаёт обновление
|
||
аварии, та же сигнатура не порождает повторное сообщение при каждом опросе.
|
||
|
||
## Доставка и дедупликация
|
||
|
||
Перед сетевой попыткой наблюдатель атомарно сохраняет её время. После
|
||
подтверждённого ответа Telegram Bot API он сохраняет `message_id` и время
|
||
успешной доставки. Это даёт следующие правила:
|
||
|
||
- первое сообщение отправляется один раз при переходе в проблему;
|
||
- после неоднозначной сетевой ошибки немедленного шторма повторов нет: новая
|
||
попытка разрешается только после отдельного интервала повторной доставки;
|
||
- для долгого неустранённого инцидента допускается редкое напоминание;
|
||
- сообщение о восстановлении отправляется только после подтверждённого
|
||
`healthy` и само повторяется безопасно, пока Telegram не подтвердит доставку;
|
||
- смена одной неуспешной попытки выпуска другой не создаёт ложную пару
|
||
«восстановлено -> снова сломано», а обновляет действующий класс инцидента.
|
||
|
||
Telegram Bot API не предоставляет ключ идемпотентности для `sendMessage`,
|
||
поэтому абсолютно исключить дубль при редком сценарии «Telegram принял
|
||
сообщение, но ответ потерялся» невозможно. Долговечное время попытки и большой
|
||
интервал повтора ограничивают этот случай одним редким повтором, а обычные
|
||
systemd-запуски полностью дедуплицируются.
|
||
|
||
## Сетевой путь до Telegram
|
||
|
||
На production-хосте нет IPv6-маршрута, а часть IPv4-адресов Telegram
|
||
заблокирована: прямой `urlopen` тратил весь таймаут на мёртвый IPv4 и падал
|
||
`ENETUNREACH` на IPv6, поэтому ни авария, ни восстановление не доставлялись.
|
||
Наблюдатель не заводит собственной сетевой политики и не импортирует код бота:
|
||
он читает тот же контракт, что и все процессы, владеющие ботом
|
||
(`_BOT_IDENTITY` в `deployment/service_env_profiles.py` VPnBot), из того же
|
||
root-only runtime-env, откуда берёт токен:
|
||
|
||
- `VPNBOT_TELEGRAM_IP_FAMILY` — семейство адресов (пусто/`ipv4` по умолчанию,
|
||
`auto`, `ipv6`); неизвестное значение — отказ
|
||
`telegram_egress_config_invalid`;
|
||
- `VPNBOT_TELEGRAM_API_FALLBACK_IPV4S` — резервные IPv4 Bot API;
|
||
- `VPNBOT_TELEGRAM_EGRESS_HEALTH_PATH` — путь проекции relay (по умолчанию
|
||
`/run/vpnbot-node-manager/telegram-egress.json`, владелец — node manager).
|
||
|
||
Порядок маршрутов повторяет бота (`telegram_egress.py`, `telegram_client.py`):
|
||
свежая (не старше 45 с по `updated_at_monotonic`) проекция со здоровыми портами
|
||
`api.telegram.org` — только эти loopback-relay; иначе резервные IPv4, затем DNS
|
||
выбранного семейства. Меняется только TCP-адрес: TLS несёт SNI
|
||
`api.telegram.org` и проверяет его сертификат.
|
||
|
||
Следующий маршрут пробуется только если соединение не установилось (TCP или
|
||
TLS) — запрос ещё не ушёл. Если запрос ушёл, а полного ответа нет, это
|
||
`telegram_delivery_ambiguous`, и в этом запуске он не повторяется: у
|
||
`sendMessage` нет ключа идемпотентности. Коды отказа: `telegram_no_route`,
|
||
`telegram_connect_failed`, `telegram_delivery_ambiguous`,
|
||
`telegram_http_status`, `telegram_response_invalid`, `telegram_rejected`.
|
||
|
||
## Безопасность
|
||
|
||
- Токен бота не копируется в новый файл. Наблюдатель читает только
|
||
`VPNBOT_BOT_TOKEN` из существующего root-only runtime-env VPnBot.
|
||
- Chat ID, URL API, интервалы и пути лежат в отдельном root-only
|
||
`/etc/vpnbot-xray-release-alert.env`.
|
||
- Состояние и lock имеют режим `0600`, каталог состояния — `0700`.
|
||
- Forgejo-релизы и Actions публичного patch-репозитория читаются без
|
||
write-токена промоутера.
|
||
- systemd запрещает повышение привилегий, запись в систему и запись в домашние
|
||
каталоги; разрешён только каталог состояния наблюдателя.
|
||
- Некорректный JSON, небезопасный путь, неизвестная структура API или ошибка
|
||
чтения не переводятся в «всё исправилось». Процесс завершается ошибкой, а
|
||
предыдущее активное состояние остаётся сохранённым.
|
||
|
||
## Проверка и выпуск
|
||
|
||
1. Unit-тестами проверить классификацию Actions, пустой публичный API при
|
||
существующем структурированном web-run, зависший runner, задержку candidate,
|
||
canary-фазы, незакрытие инцидента во время retry, дедупликацию, напоминание
|
||
и восстановление.
|
||
2. Прогнать полную существующую проверку репозитория патчей и сборку поверх
|
||
официального Xray.
|
||
3. Опубликовать один commit в Forgejo и дождаться успешных `ci.yml` и
|
||
`candidate.yml`.
|
||
4. Установить service/timer на production, создать только конфигурационный
|
||
env без секретов и отправить явно помеченное тестовое сообщение оператору.
|
||
5. Запустить обычную проверку в текущем здоровом состоянии: новых аварийных
|
||
сообщений быть не должно, unit должен завершиться успешно, состояние должно
|
||
зафиксировать три здоровых условия.
|
||
6. Обновить проектный навык сопровождения Xray: зафиксировать новые unit,
|
||
runtime-пути, значения состояний и команды проверки.
|