GoToSocial молча съедал все статусы с релеев — и я нашёл почему

TL;DR: Подписка на релей выглядит рабочей, релей шлёт, нода отвечает 202 Accepted — а в базе пусто. Причина — я не поставил галочку Match posts by default, а матчеры оставил пустыми. В таком виде фильтр релеев по построению возвращает false для всего. Статусы дропаются молча, на уровне debug. Ошибка тихая: её заслоняют громкие ошибки dereference.

Как это выглядит снаружи

Ты подписан на релей. Ты видишь:

  • подписка активна, approved = true,
  • релей исправно шлёт Announce,
  • нода отвечает 202 Accepted.

Всё зелёное. А лента месяцами состоит из пары доменов, на которые ты подписан напрямую. Релей как будто «работает вхолостую».

❌ Моя ошибка

Я настроил подписку так:

  • ✅ разрешил public
  • ✅ разрешил unlisted
  • ✅ запретил sensitive

И решил, что этого достаточно. Логика была: «я разрешил то, что хочу, и запретил то, что не хочу — значит, всё остальное будет приходить само».

Но это не так. Разрешение public/unlisted и запрет sensitive — это фильтры видимости. Они говорят, какие типы постов можно принимать. Но они не дают разрешения на приём вообще. Разрешение даёт либо match_by_default, либо include-матчеры. У меня не было ни того, ни другого.

Я не поставил галочку Match posts by default. Без неё подписка работает в режиме deny-by-default: пропускает только то, что явно разрешено матчерами. Матчеров нет → не проходит ничего.

Что происходит под капотом

  1. Релей шлёт POST /inbox → нода отвечает 202 Accepted. Это подтверждение приёма HTTP-запроса, а не сохранения статуса.
  2. Нода скачивает оригинал по URI (dereference), тратит трафик и время.
  3. Статус идёт в relay.Filter.MatchedBySubscription.
  4. Нет совпадения → статус выбрасывается. В лог падает dropping unpermitted statuswarn/debug, не error.

Где прячется грабль

В таблице relay_subscriptions два ключевых поля: flags (битовая маска) и matchers (JSON-правила).

Флаги из gtsmodel/relay.go:

RelayFlagPublic          = 2   (принимать публичные)
RelayFlagUnlisted        = 4   (принимать скрытые)
RelayFlagMatchByDefault  = 8   (принимать всё по умолчанию)
RelayFlagIgnoreSensitive = 16  (игнорировать чувствительное)
RelayFlagIgnoreMedia     = 32  (игнорировать с медиа)
RelayFlagIgnoreReplies   = 64  (игнорировать ответы)

У меня на всех трёх подписках стояло flags = 22. Раскладываем: 16 + 4 + 2 = IgnoreSensitive + Unlisted + Public. Выглядит осмысленно, правда? «Принимаем публичные и скрытые, игнорируем чувствительное».

Но бита MatchByDefault (8) там нет. И matchers = NULL.

Почему без match_by_default дропается всё

Логика matchedByConnection в internal/filter/relay/relay.go:

  1. Видимость: public → нужен флаг Public (есть), unlisted → нужен Unlisted (есть), остальное → false.
  2. Чувствительное + IgnoreSensitive → false (это намеренно).
  3. Медиа + IgnoreMedia → false (не стоит).
  4. Ответ не себе + IgnoreReplies → false (не стоит).
  5. Exclude-матчеры (чёрный список). Их нет → пропускаем.
  6. Если стоит MatchByDefault → true. У меня не стоит.
  7. Иначе ищем include-матчеры (белый список). Их нет вообще.
  8. return false.

Вот оно. Пустая подписка без матчеров — это подписка, которая не пропускает ничего. Deny-by-default. Не «не знаю», а именно «не разрешаю».

Что такое матчеры и зачем они нужны

Матчер — это ключевое слово, которое ищется в содержимом поста и в его content warning. Поиск регистронезависимый. Есть два режима совпадения: partial (по умолчанию, ловит часть слова) и whole word (только целое слово). Хэштеги матчатся через префикс #.

Матчеры бывают двух типов:

Include-матчеры (белый список). Работают, когда match_by_default выключен. Пост пройдёт только если совпал хотя бы с одним include-матчером. Нет include-матчеров и нет match_by_default — не пройдёт ничего. Именно это и случилось у меня.

Exclude-матчеры (чёрный список). Работают всегда, независимо от match_by_default. Если пост совпал с exclude-матчером — он дропается, даже если match_by_default включён.

Матчеры дают админу хирургический контроль вместо грубого «всё или ничего». Include — «хочу только посты про infosec и Linux». Exclude — «принимай всё, кроме спама и nsFW».

Как диагностировать

SQL:

1
2
SELECT relay_actor_uri, flags, matchers FROM relay_subscriptions;
-- flags без бита 8 и matchers = NULL → подписка не пропускает ничего

API:

GET /api/v1/admin/relay_subscriptions
→ смотрим match_by_default

✅ Как чинить — и что надо было сделать сразу

Два корректных пути при добавлении релея:

Путь 1: «Принимать всё, кроме явных запретов» — поставить галочку Match posts by default. Тогда работают только exclude-матчеры и ignore-флаги. Всё, что не попало под запрет, — принимается.

Путь 2: «Принимать только то, что я явно указал» — не ставить Match posts by default, но создать include-матчеры. Например, infosec, linux, #GoToSocial.

Я выбрал ни то, ни другое. Надо было поставить галочку:

PUT /api/v1/admin/relay_subscriptions/{id}
{ "public": true, "unlisted": true, "match_by_default": true }

После включения приток пошёл мгновенно: 44 статуса за 30 минут, 12+ новых доменов (infosec.exchange, mastodon.world, burningboard.net, troet.cafe, norden.social, c.im, toot.wales, social.linux.pizza…). До этого лента месяцами содержала только пару доменов прямых подписок.

Выводы

  1. Главное правило: если хочешь «принимать всё, кроме запрещённого» — обязательно ставь галочку Match posts by default. Без неё релей будет слать, нода будет отвечать 202 Accepted, а лента останется пустой.
  2. Разрешить public/unlisted недостаточно — это лишь фильтры видимости, а не разрешение на приём. «Подписка есть» и «релей шлёт» ≠ «контент сохраняется».
  3. Дизайн фильтра разумный — безопасный дефолт. Но для админа неочевидный: UI не кричит, что подписка без матчеров мёртвая.
  4. Мониторь не только error, но и warn-строки dropping unpermitted / not relayable.
  5. 202 Accepted — это «запрос принят», а не «статус сохранён». Путать их — самый дешёвый способ незаметно потерять федерацию.

Теги: #GoToSocial #ActivityPub #Fediverse #Relay #администрирование #грабли

Если у кого-то была та же тишина в ленте при живом релее — проверьте match_by_default. Возможно, вы тоже кормите чёрную дыру.