From cfd0ec0ce9dd442c43f5c11fd02fa660ce2bf62e Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Sun, 23 Aug 2026 07:54:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=83=D1=88=D0=B8=20=D0=BD=D0=B0=20iOS:?= =?UTF-8?q?=20sub=20=D1=83=D0=B5=D0=B7=D0=B6=D0=B0=D0=BB=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BA=20mailto:mailto:,=20Apple=20=D0=BE=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=87=D0=B0=D0=BB=20BadJwtToken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ --- docs/decisions/047-push-endpoint.md | 2 +- .../064-push-failure-reason-in-log.md | 24 ++ docs/deploy.md | 4 +- internal/config/config.go | 38 +++ internal/config/config_test.go | 58 ++++ internal/push/push.go | 110 +++++++- internal/push/push_test.go | 250 ++++++++++++++++++ 7 files changed, 477 insertions(+), 9 deletions(-) create mode 100644 docs/decisions/064-push-failure-reason-in-log.md create mode 100644 internal/config/config_test.go diff --git a/docs/decisions/047-push-endpoint.md b/docs/decisions/047-push-endpoint.md index 94e6db2..20878ab 100644 --- a/docs/decisions/047-push-endpoint.md +++ b/docs/decisions/047-push-endpoint.md @@ -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). ## Контекст diff --git a/docs/decisions/064-push-failure-reason-in-log.md b/docs/decisions/064-push-failure-reason-in-log.md new file mode 100644 index 0000000..65929f9 --- /dev/null +++ b/docs/decisions/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). diff --git a/docs/deploy.md b/docs/deploy.md index 16b7331..6219c83 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -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` остаётся: это журнал сбоев, а не запросов, и при отказе он записывает адрес клиента. diff --git a/internal/config/config.go b/internal/config/config.go index 67579ad..848cca5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -68,9 +68,47 @@ func Load() (*Config, error) { return nil, fmt.Errorf("%s пуст: уберите переменную, чтобы взять значение по умолчанию, или задайте непустое", v.key) } } + if err := checkVAPIDSubject(c); err != nil { + return nil, err + } return c, nil } +// checkVAPIDSubject проверяет форму VAPID-субъекта при старте. Ошибка +// в нём иначе не видна вовсе: сервер поднимается, подписки ставятся, +// а push-сервис отвергает каждый токен — «пуши не приходят» без единой +// строки о причине. Отказ на старте дешевле. +// +// Годится ровно то, что допускает RFC 8292: «mailto:<адрес>» или +// «https://<хост>» (docs/deploy.md). +func checkVAPIDSubject(c *Config) error { + if c.VAPIDSubject == "" { + // Без ключей пуши и так выключены — это рабочий локальный запуск. + // А вот ключи без субъекта означают, что настроить пуши хотели + // и не настроили. + if c.VAPIDPublic == "" && c.VAPIDPrivate == "" { + return nil + } + return fmt.Errorf("BARE_VAPID_SUBJECT пуст при заданных VAPID-ключах: нужен mailto:<адрес> или https://<хост>") + } + bad := fmt.Errorf("BARE_VAPID_SUBJECT=%q не годится: нужен mailto:<адрес> или https://<хост>", c.VAPIDSubject) + if addr, ok := strings.CutPrefix(c.VAPIDSubject, "mailto:"); ok { + local, domain, at := strings.Cut(addr, "@") + if !at || local == "" || domain == "" || strings.ContainsAny(addr, " \t") { + return bad + } + return nil + } + if rest, ok := strings.CutPrefix(c.VAPIDSubject, "https://"); ok { + host, _, _ := strings.Cut(rest, "/") + if host == "" || strings.ContainsAny(rest, " \t") { + return bad + } + return nil + } + return bad +} + func env(key, fallback string) string { if v, ok := os.LookupEnv(key); ok { return strings.TrimSpace(v) diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..0f308b4 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,58 @@ +package config + +import ( + "strings" + "testing" +) + +// Форма VAPID-субъекта проверяется на старте: с мусором в нём сервер +// поднимается, подписки ставятся, а push-сервис отвергает каждый токен — +// поломка без единой строки в журнале (docs/deploy.md). +func TestVAPIDSubject(t *testing.T) { + cases := []struct { + subject string + keys bool + ok bool + }{ + {"mailto:admin@xmatic.team", true, true}, + {"https://bare.xmatic.team", true, true}, + {"https://bare.xmatic.team/", true, true}, + // Голый адрес — не URI: RFC 8292 требует схему, и APNs отвергает + // токен без неё. + {"admin@xmatic.team", true, false}, + {"mailto:", true, false}, + {"mailto:admin", true, false}, + {"mailto:@xmatic.team", true, false}, + {"mailto:admin@xmatic.team, second@xmatic.team", true, false}, + {"https://", true, false}, + {"http://bare.xmatic.team", true, false}, + {"bare.xmatic.team", true, false}, + // Ключи заданы, субъекта нет: пуши настроить хотели и не настроили. + {"", true, false}, + // Ни ключей, ни субъекта — локальный запуск без пушей. + {"", false, true}, + } + for _, c := range cases { + t.Setenv("BARE_VAPID_SUBJECT", c.subject) + if c.keys { + t.Setenv("BARE_VAPID_PUBLIC", "public") + t.Setenv("BARE_VAPID_PRIVATE", "private") + } else { + t.Setenv("BARE_VAPID_PUBLIC", "") + t.Setenv("BARE_VAPID_PRIVATE", "") + } + _, err := Load() + if c.ok && err != nil { + t.Errorf("BARE_VAPID_SUBJECT=%q (ключи: %v): %v", c.subject, c.keys, err) + } + if !c.ok { + if err == nil { + t.Errorf("BARE_VAPID_SUBJECT=%q (ключи: %v): принят", c.subject, c.keys) + continue + } + if !strings.Contains(err.Error(), "BARE_VAPID_SUBJECT") { + t.Errorf("ошибка не называет переменную: %v", err) + } + } + } +} diff --git a/internal/push/push.go b/internal/push/push.go index c758adb..10561bf 100644 --- a/internal/push/push.go +++ b/internal/push/push.go @@ -15,6 +15,7 @@ import ( "net" "net/http" "net/netip" + "strings" "sync" "syscall" "time" @@ -69,6 +70,16 @@ const ( dropEvery = time.Minute ) +// Чтение тела ответа push-сервиса ради кода причины (ADR-064). +const ( + // maxReasonBody — сколько байт тела читаем. Код причины стоит в начале + // ответа; остальное дочитывается в никуда, ради переиспользования + // соединения. + maxReasonBody = 200 + // maxReason — предел длины кода причины в журнале. + maxReason = 64 +) + // Devices — что отправителю нужно от хранилища. Правило «одно молчащее // устройство — один пуш» держится на атомарном захвате (ADR-023). type Devices interface { @@ -110,9 +121,11 @@ type Sender struct { connected func(device string) bool public string private string - subject string - client *http.Client - logw io.Writer + // subject — VAPID-субъект в той форме, которую ждёт webpush-go: + // у «mailto:» схема снята, см. vapidSubscriber. + subject string + client *http.Client + logw io.Writer jobs chan job done chan struct{} @@ -149,7 +162,7 @@ func New(cfg *config.Config, devices Devices, connected func(device string) bool connected: connected, public: cfg.VAPIDPublic, private: cfg.VAPIDPrivate, - subject: cfg.VAPIDSubject, + subject: vapidSubscriber(cfg.VAPIDSubject), client: &http.Client{ Timeout: requestTimeout, // Push-сервисы редиректов не шлют. Следование за ними @@ -180,6 +193,35 @@ func (s *Sender) on() bool { return s.public != "" && s.private != "" && s.subject != "" } +// vapidSubscriber приводит BARE_VAPID_SUBJECT к форме, которую ждёт +// webpush-go. Нормализация здесь не косметика: без неё пуши не уходят +// вовсе, ни на одной платформе. +// +// По RFC 8292 поле sub в VAPID-токене — это URI: «mailto:<адрес>» или +// «https://<хост>». Именно так субъект и записан в окружении +// (docs/deploy.md), и менять запись нельзя — она верна. Но webpush-go +// в getVAPIDAuthorizationHeader считает субъектом голый адрес и сам +// приписывает схему всему, что не начинается с «https:»: +// +// if !strings.HasPrefix(subscriber, "https:") { +// subscriber = "mailto:" + subscriber +// } +// +// Готовый «mailto:admin@example.org» превращается в +// «mailto:mailto:admin@example.org», и push-сервис отвергает токен: +// APNs отвечает 403 BadJwtToken на каждый пуш. Обойти это настройкой +// нельзя — голый адрес в sub тот же APNs тоже отвергает 403. Поэтому +// схему снимаем ровно перед вызовом библиотеки: в токен она вернётся, +// а конфигурация остаётся правильной по спецификации. +// +// «https:» отдаётся как есть: его библиотека узнаёт и не трогает. +func vapidSubscriber(subject string) string { + if strings.HasPrefix(subject, "https:") { + return subject + } + return strings.TrimPrefix(subject, "mailto:") +} + // Send ставит пуш каждому из устройств в очередь отправки и возвращается // сразу: конверт уже в очереди устройства, ответ на POST /api/messages // пуша не ждёт (ADR-023). @@ -289,8 +331,9 @@ func (s *Sender) deliver(j job) { return } defer resp.Body.Close() - // Тело ответа push-сервиса нам не нужно, но дочитать его стоит: - // иначе соединение не переиспользуется. + // Начало тела нужно ради кода причины (ADR-064), остаток дочитывается + // в никуда: иначе соединение не переиспользуется. + head, _ := io.ReadAll(io.LimitReader(resp.Body, maxReasonBody)) io.Copy(io.Discard, resp.Body) switch { @@ -305,7 +348,7 @@ func (s *Sender) deliver(j job) { // Подписки больше нет — чистим мёртвую (ADR-011). s.drop(j.device) default: - s.report("push-сервис ответил %d", resp.StatusCode) + s.report("push-сервис ответил %s", status(resp.StatusCode, head)) s.release(j.device) } } @@ -388,6 +431,59 @@ func (s *Sender) report(format string, args ...any) { fmt.Fprintf(s.logw, "%s пуш: %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...)) } +// status — ответ push-сервиса для журнала: код и, если он разобран, +// короткий код причины из тела (ADR-064). +func status(code int, body []byte) string { + if r := serviceReason(body); r != "" { + return fmt.Sprintf("%d (%s)", code, r) + } + return fmt.Sprintf("%d", code) +} + +// serviceReason достаёт из тела ответа короткий код причины: APNs отвечает +// {"reason":"BadJwtToken"}, Mozilla — {"errno":…,"error":"Not Found"}. +// Код — диагностика вендора, а не данные пользователя, и без него отказ +// не читается: голый «403» сутки выглядел как «что-то с пушами» (ADR-064). +// +// Тело всё же приходит снаружи, поэтому в журнал идёт не оно, а то, что +// прошло safeReason. +func serviceReason(body []byte) string { + var fields map[string]json.RawMessage + if json.Unmarshal(body, &fields) == nil { + for _, key := range []string{"reason", "error", "message"} { + var s string + if json.Unmarshal(fields[key], &s) == nil && s != "" { + return safeReason(s) + } + } + } + return safeReason(string(body)) +} + +// safeReason пропускает только то, что заведомо является кодом причины: +// одна строка не длиннее maxReason из латиницы, цифр, «_», «-» и пробелов. +// Точка, «:», «/» и «@» встречаются в адресах подписки и именах хостов, +// поэтому строка с ними отбрасывается целиком — в журнале остаётся один +// статус (docs/deploy.md, «Логи»). +func safeReason(s string) string { + s = strings.TrimSpace(s) + if i := strings.IndexAny(s, "\r\n"); i >= 0 { + s = strings.TrimSpace(s[:i]) + } + if s == "" || len(s) > maxReason { + return "" + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + case r == ' ', r == '_', r == '-': + default: + return "" + } + } + return s +} + // errLocalAddress — попытка соединиться с непубличным адресом (ADR-047). var errLocalAddress = errors.New("push: адрес не публичный") diff --git a/internal/push/push_test.go b/internal/push/push_test.go index 1a0e9cf..57e386e 100644 --- a/internal/push/push_test.go +++ b/internal/push/push_test.go @@ -2,15 +2,22 @@ package push import ( "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "encoding/json" "errors" "fmt" + "io" "net" "net/http" "net/http/httptest" "net/netip" "net/url" "strings" + "sync" "testing" + "time" "github.com/xmatic-squad/bare/internal/config" ) @@ -143,3 +150,246 @@ func TestReasonWithoutEndpoint(t *testing.T) { } } } + +// В VAPID-токен уходит субъект ровно в той форме, которую требует +// RFC 8292: у «mailto:» одна схема, а не две. webpush-go приписывает +// «mailto:» всему, что не начинается с «https:», поэтому готовый URI +// приходится отдавать ему без схемы — см. vapidSubscriber. Без этого +// APNs отвечает 403 BadJwtToken на каждый пуш. +func TestVAPIDSubjectInToken(t *testing.T) { + cases := []struct{ subject, want string }{ + {"mailto:admin@xmatic.team", "mailto:admin@xmatic.team"}, + {"https://bare.xmatic.team", "https://bare.xmatic.team"}, + {"admin@xmatic.team", "mailto:admin@xmatic.team"}, + } + for _, c := range cases { + t.Run(c.subject, func(t *testing.T) { + got := make(chan string, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(io.Discard, r.Body) + select { + case got <- r.Header.Get("Authorization"): + default: + } + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + s, _ := sender(t, c.subject, srv.URL, nil) + defer s.Close() + s.Send([]Target{{Device: "d1", Owner: "marta"}}, Payload{Title: "@marta", Chat: "dm:marta"}) + + var header string + select { + case header = <-got: + case <-time.After(wait): + t.Fatal("push-сервис не получил запроса") + } + if sub := subClaim(t, header); sub != c.want { + t.Errorf("sub: получено %q, ожидалось %q", sub, c.want) + } + }) + } +} + +// Отказ push-сервиса читается по журналу: статус и код причины из тела +// (ADR-064). Без кода 403 от APNs неотличим от любого другого отказа. +func TestServiceStatusInLog(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(io.Discard, r.Body) + w.WriteHeader(http.StatusForbidden) + io.WriteString(w, `{"reason":"BadJwtToken"}`) + })) + defer srv.Close() + + log := &logbuf{} + s, d := sender(t, "mailto:admin@xmatic.team", srv.URL, log) + defer s.Close() + s.Send([]Target{{Device: "d1", Owner: "marta"}}, Payload{Title: "@marta", Chat: "dm:marta"}) + + const want = "пуш: push-сервис ответил 403 (BadJwtToken)" + deadline := time.Now().Add(wait) + for !strings.Contains(log.String(), want) && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if line := log.String(); !strings.Contains(line, want) { + t.Errorf("журнал: %q, ожидалась строка %q", line, want) + } + if strings.Contains(log.String(), srv.URL) || strings.Contains(log.String(), "d1") { + t.Errorf("в журнал попал адрес подписки или устройство: %q", log.String()) + } + if d.drops() != 0 { + t.Error("подписка снята по ответу 403") + } +} + +// Код причины берётся из тела ответа, но телом распоряжается чужая +// сторона: всё, что на короткий код не похоже, в журнал не идёт вовсе +// (ADR-064, docs/deploy.md, «Логи»). +func TestServiceReason(t *testing.T) { + const endpoint = "web.push.apple.com" + cases := []struct{ body, want string }{ + {`{"reason":"BadJwtToken"}`, "BadJwtToken"}, + {`{"reason":"TooManyRequests"}`, "TooManyRequests"}, + {`{"code":404,"errno":103,"error":"Not Found"}`, "Not Found"}, + {"Unauthorized registration", "Unauthorized registration"}, + {`{"error":{"code":403,"status":"UNAUTHENTICATED"}}`, ""}, + {`{"reason":"unknown push endpoint https://` + endpoint + `/QK"}`, ""}, + {"gone: " + endpoint, ""}, + {`{"message":"subscription ` + endpoint + ` expired"}`, ""}, + {strings.Repeat("A", maxReason+1), ""}, + {"", ""}, + // Многострочное тело: в журнал идёт первая строка, остальное + // отбрасывается вместе с адресом. + {"BadJwtToken\nendpoint: " + endpoint, "BadJwtToken"}, + } + for _, c := range cases { + got := serviceReason([]byte(c.body)) + if got != c.want { + t.Errorf("serviceReason(%q): получено %q, ожидалось %q", c.body, got, c.want) + } + if strings.Contains(got, endpoint) { + t.Errorf("адрес подписки попал в журнал: %q", got) + } + } +} + +// Статус без разобранного кода причины остаётся статусом. +func TestStatusWithoutReason(t *testing.T) { + if got := status(500, nil); got != "500" { + t.Errorf("status: получено %q, ожидалось %q", got, "500") + } + if got := status(403, []byte(`{"reason":"BadJwtToken"}`)); got != "403 (BadJwtToken)" { + t.Errorf("status: получено %q, ожидалось %q", got, "403 (BadJwtToken)") + } +} + +// wait — сколько ждём отправку. Всё локально, задержек быть не должно. +const wait = 5 * time.Second + +// sender — отправитель с настоящей парой VAPID-ключей и одной подпиской +// на подменный push-сервис. Без ключей отправщики не заводятся. +func sender(t *testing.T, subject, endpoint string, logw io.Writer) (*Sender, *devices) { + t.Helper() + key, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("vapid: %v", err) + } + d := &devices{subscription: subscription(t, endpoint)} + cfg := &config.Config{ + VAPIDPublic: base64.RawURLEncoding.EncodeToString(key.PublicKey().Bytes()), + VAPIDPrivate: base64.RawURLEncoding.EncodeToString(key.Bytes()), + VAPIDSubject: subject, + // Подменный push-сервис живёт на 127.0.0.1; в работе отправщик + // ходит только по публичным адресам (ADR-047). + PushLocal: true, + } + return New(cfg, d, nil, logw), d +} + +// subscription — подписка устройства с настоящими ключами: webpush-go +// шифрует ими нагрузку, случайных байт ему мало. +func subscription(t *testing.T, endpoint string) string { + t.Helper() + key, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("ключ подписки: %v", err) + } + auth := make([]byte, 16) + if _, err := rand.Read(auth); err != nil { + t.Fatalf("секрет подписки: %v", err) + } + raw, err := json.Marshal(map[string]any{ + "endpoint": endpoint + "/push", + "keys": map[string]string{ + "p256dh": base64.RawURLEncoding.EncodeToString(key.PublicKey().Bytes()), + "auth": base64.RawURLEncoding.EncodeToString(auth), + }, + }) + if err != nil { + t.Fatalf("подписка: %v", err) + } + return string(raw) +} + +// subClaim достаёт sub из VAPID-заголовка: «vapid t=, k=<ключ>». +// JWT разбирается руками — библиотека здесь и проверяется. +func subClaim(t *testing.T, header string) string { + t.Helper() + rest, ok := strings.CutPrefix(header, "vapid t=") + if !ok { + t.Fatalf("заголовок не vapid: %q", header) + } + token, _, ok := strings.Cut(rest, ",") + if !ok { + t.Fatalf("в заголовке нет ключа: %q", header) + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("не JWT: %q", token) + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatalf("claims: %v", err) + } + var claims struct { + Sub string `json:"sub"` + } + if err := json.Unmarshal(raw, &claims); err != nil { + t.Fatalf("claims: %v", err) + } + return claims.Sub +} + +// devices — хранилище устройств в тесте: право на пуш даётся всегда, +// возвраты и снятия считаются. +type devices struct { + subscription string + + mu sync.Mutex + released int + dropcount int +} + +func (d *devices) ClaimPush(context.Context, string) (string, bool, error) { + return d.subscription, true, nil +} + +func (d *devices) ReleasePush(context.Context, string) error { + d.mu.Lock() + defer d.mu.Unlock() + d.released++ + return nil +} + +func (d *devices) DropPush(context.Context, string) error { + d.mu.Lock() + defer d.mu.Unlock() + d.dropcount++ + return nil +} + +func (d *devices) drops() int { + d.mu.Lock() + defer d.mu.Unlock() + return d.dropcount +} + +// logbuf — журнал теста. Пишет в него отправщик, читает тест, поэтому +// с замком. +type logbuf struct { + mu sync.Mutex + b strings.Builder +} + +func (l *logbuf) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.b.Write(p) +} + +func (l *logbuf) String() string { + l.mu.Lock() + defer l.mu.Unlock() + return l.b.String() +}