Пуши на iOS: sub уезжал как mailto:mailto:, Apple отвечал BadJwtToken

webpush-go в getVAPIDAuthorizationHeader приписывает "mailto:" всему,
что не начинается с "https:". В /etc/bare/env лежит правильная по
спецификации VAPID форма mailto:admin@xmatic.team, и в JWT уходил
sub = mailto:mailto:admin@xmatic.team. Apple отвергал токен, пуши
не работали ни разу за всё время.

Проверено экспериментом на живом endpoint: mailto:<адрес> → 201,
https://<хост> → 201, mailto:mailto:<адрес> → 403 BadJwtToken,
голый адрес без схемы → 403. Значит убрать схему из конфига нельзя:
адаптируется код, а не операционный файл.

Субъект нормализуется один раз при создании отправщика, форма
BARE_VAPID_SUBJECT проверяется при старте — негодное значение валит
запуск с понятной ошибкой, а не выключает пуши молча.

ADR-064: в журнал пишется код причины от push-сервиса. Без него отказ
был неотличим от любого другого 403, и дефект прожил незамеченным
весь этап 4 и всю закалку. Адрес подписки в журнал по-прежнему
не попадает: наружу идёт только короткий код из латиницы и цифр.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
2026-08-23 07:54:16 +03:00
co-authored by Claude Opus 5
parent 522ba89e36
commit cfd0ec0ce9
7 changed files with 477 additions and 9 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# ADR-047: Исходящий запрос к push-сервису
Уточняет [ADR-011](011-web-push.md) и [ADR-023](023-push-and-service-worker.md).
Уточняет [ADR-011](011-web-push.md) и [ADR-023](023-push-and-service-worker.md). Код причины из ответа push-сервиса в журнале — [ADR-064](064-push-failure-reason-in-log.md).
## Контекст
@@ -0,0 +1,24 @@
# ADR-064: Код причины отказа push-сервиса в журнале
Уточняет [ADR-047](047-push-endpoint.md).
## Контекст
ADR-047 убрал из журнала адрес подписки: отказ отправки пишется классом, текст ошибки транспорта не печатается вовсе. Под то же правило попал и ответ push-сервиса — в журнал уходил один статус: `пуш: push-сервис ответил 403`.
Цена выяснилась на работающем сервере. APNs отвечал `403` на каждый пуш, потому что в VAPID-токен уезжал `sub = "mailto:mailto:admin@xmatic.team"`: webpush-go приписывает `mailto:` всему, что не начинается с `https:`, а в окружении субъект записан правильным URI. Сутки не уходило ни одного пуша, и по журналу это выглядело как «что-то с пушами»: просроченный ключ, чужой субъект, лимит вендора и мёртвая подписка дают один и тот же `403`. Причина лежала в теле ответа: `{"reason":"BadJwtToken"}`.
Код причины от push-сервиса — диагностика вендора, а не данные пользователя. Но тело ответа приходит снаружи, и класть его в журнал целиком нельзя: в нём может оказаться адрес подписки.
## Решение
- В журнал уходит статус и, если он разобран, короткий код причины: `пуш: push-сервис ответил 403 (BadJwtToken)`.
- Читается не больше 200 байт тела; остаток дочитывается ради переиспользования соединения. Код берётся из полей JSON `reason`, `error`, `message` — в этом порядке — либо из тела целиком, если оно само короткая строка.
- В журнал идёт только первая строка не длиннее 64 символов из латиницы, цифр, `_`, `-` и пробелов. Точка, `:`, `/` и `@` встречаются в адресах подписки и именах хостов, поэтому строка с ними отбрасывается целиком и остаётся один статус.
- Правило ADR-047 про транспорт не меняется: ошибка соединения по-прежнему сводится к классу, её текст не печатается, адреса подписки в журнале нет.
## Следствия
- Отказ push-сервиса читается по журналу и чинится по нему же.
- Часть вендорских кодов не разберётся: FCM отвечает вложенным объектом, а коды с точкой в имени не проходят алфавит. Тогда в журнале остаётся статус, как раньше. Осознанно: молчание безопаснее догадок о чужом теле.
- Ответы `404` и `410` пишутся не строкой, а снятием подписки — так было и остаётся (ADR-011).
+3 -1
View File
@@ -35,6 +35,8 @@ BARE_INVITE_CODE=<пусто или код>
`bare vapid` печатает пару ключей; выполняется локально один раз, результат вписывается в файл.
`BARE_VAPID_SUBJECT` — URI по RFC 8292: `mailto:<адрес>` или `https://<хост>`. Форма проверяется при старте: с пустым при заданных ключах или с голым адресом без схемы сервер не поднимается. Отдавать субъект библиотеке приходится без схемы `mailto:` — она приписывает её сама, и готовый URI превратился бы в `mailto:mailto:…`, который push-сервис отвергает; нормализация живёт в `internal/push`, запись в этом файле верна и не меняется.
`/etc/systemd/system/bare.service`:
```ini
@@ -144,6 +146,6 @@ ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sud
## Логи
Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ника в журнале нет вовсе, включая отказы по лимитам (ADR-055); IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. Отправитель пушей пишет класс отказа — «таймаут», «имя не разрешилось», «отправка не удалась» — без адреса подписки и идентификатора устройства (ADR-047). journald хранит по своим правилам.
Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ника в журнале нет вовсе, включая отказы по лимитам (ADR-055); IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. Отправитель пушей пишет класс отказа — «таймаут», «имя не разрешилось», «отправка не удалась» — без адреса подписки и идентификатора устройства (ADR-047). Ответ push-сервиса пишется статусом и коротким кодом причины из тела: «пуш: push-сервис ответил 403 (BadJwtToken)». Код — диагностика вендора; всё, что на короткий код не похоже, отбрасывается целиком, и остаётся один статус (ADR-064). journald хранит по своим правилам.
nginx журнал запросов не ведёт: `access_log off` в обоих server-блоках (ADR-056). Без этой строки он унаследовал бы `access.log` формата `combined` из `/etc/nginx/nginx.conf` — с адресом клиента и полным URI, то есть с ником и социальным графом. `error_log` остаётся: это журнал сбоев, а не запросов, и при отказе он записывает адрес клиента.