Пуши на 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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user