diff --git a/content/posts/20260913 - gotosocial relay match by default/hero.svg b/content/posts/20260913 - gotosocial relay match by default/hero.svg new file mode 100644 index 0000000..50fd2c1 --- /dev/null +++ b/content/posts/20260913 - gotosocial relay match by default/hero.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + GoToSocial и молча съеденные статусы + + + ActivityPub · Relay · match_by_default · deny-by-default + + + + + \ No newline at end of file diff --git a/content/posts/20260913 - gotosocial relay match by default/index.md b/content/posts/20260913 - gotosocial relay match by default/index.md new file mode 100644 index 0000000..99a4e25 --- /dev/null +++ b/content/posts/20260913 - gotosocial relay match by default/index.md @@ -0,0 +1,164 @@ +--- +date: '2026-09-13T03:00:00+03:00' +lastmod: '2026-09-13T03:00:00+03:00' +draft: false +title: 'GoToSocial молча съедал все статусы с релеев — и я нашёл почему' +slug: 'gotosocial-relay-match-by-default' +description: 'Подписка на релей выглядит рабочей: релей шлёт, нода отвечает 202 Accepted — а в базе пусто. Разбираюсь, почему: без галочки Match posts by default и пустых матчерах фильтр релеев GoToSocial по построению возвращает false, и статусы дропаются молча на уровне debug.' + +categories: + - 'DevOps' + - 'Federation' + +tags: + - 'gotosocial' + - 'activitypub' + - 'fediverse' + - 'relay' + - 'mastodon' + - 'admin' + +keywords: + - 'GoToSocial' + - 'ActivityPub' + - 'relay subscription' + - 'match by default' + - 'matchedByConnection' + - 'relay_subscriptions' + - '202 Accepted' + - 'dereference' + - 'dropping unpermitted' + - 'deny-by-default' + +cover: + image: "hero.svg" + alt: "GoToSocial: релей молча съедает статусы — match_by_default" +--- + +# 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 status` — **warn/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: + +```sql +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`. Возможно, вы тоже кормите чёрную дыру. \ No newline at end of file