<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Gluek&apos;s blog</title><description>Персональный сайт и блог gluek.info</description><link>https://gluek.info/</link><item><title>Зеркала gluek.info в Yggdrasil и Reticulum</title><link>https://gluek.info/blog/zerkala-gluek-info-v-yggdrasil-i-reticulum/</link><guid isPermaLink="true">https://gluek.info/blog/zerkala-gluek-info-v-yggdrasil-i-reticulum/</guid><description>К зеркалам в i2p и tor добавились ещё два: в mesh-сети Yggdrasil и в Reticulum (узел NomadNet), а также LXMF-бот для подписки на новые посты.</description><pubDate>Thu, 29 Oct 2026 22:22:00 GMT</pubDate><content:encoded>&lt;p&gt;К &lt;a href=&quot;https://gluek.info/blog/zerkala-gluek-info-v-i2p-i-tor/&quot;&gt;зеркалам в i2p и tor&lt;/a&gt; добавились ещё два.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Yggdrasil&lt;/strong&gt;:&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;http://%5B201:ffff:e518:7af1:c905:eb92:c0de:7777%5D/&quot;&gt;http://[201:ffff:e518:7af1:c905:eb92:c0de:7777]/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reticulum&lt;/strong&gt;, узел NomadNet — текстовая версия блога в разметке Micron, с пережатыми картинками:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;77777777db2b0274d56ae73fb7518a4a&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Открывается в NomadNet, MeshChat и Columba на телефоне.&lt;/p&gt;
&lt;p&gt;А чтобы новые посты приходили прямо в Reticulum, я сделал &lt;a href=&quot;https://gluek.info/projects/lxmf-publish/&quot;&gt;LXMF-бота&lt;/a&gt; &lt;code&gt;777777779e7beceb13399c0ab0c63613&lt;/code&gt;. Напишите ему «подписаться» из Sideband, Columba или MeshChat, а «последние» покажет свежие посты без подписки.&lt;/p&gt;
&lt;p&gt;Адреса подобраны красивые: в Reticulum оба начинаются с &lt;code&gt;77777777&lt;/code&gt;, в Yggdrasil заканчивается на &lt;code&gt;c0de:7777&lt;/code&gt;. Все зеркала собираются тем же CI, что и основной сайт, при каждом коммите.&lt;/p&gt;
</content:encoded></item><item><title>lxmf_publish</title><link>https://gluek.info/projects/lxmf-publish/</link><guid isPermaLink="true">https://gluek.info/projects/lxmf-publish/</guid><description>LXMF-бот, который рассылает новые посты gluek.info подписчикам по сети Reticulum</description><pubDate>Thu, 29 Oct 2026 22:22:00 GMT</pubDate><content:encoded>&lt;p&gt;Брат-близнец &lt;a href=&quot;https://gluek.info/projects/deltachat-publish/&quot;&gt;deltachat_publish&lt;/a&gt;, только в обратную сторону: тот публикует посты в блог, а этот доставляет их читателям по mesh-сети &lt;strong&gt;&lt;a href=&quot;https://reticulum.network&quot;&gt;Reticulum&lt;/a&gt;&lt;/strong&gt;. Работает в паре с &lt;a href=&quot;https://gluek.info/blog/zerkala-gluek-info-v-yggdrasil-i-reticulum/&quot;&gt;зеркалом блога на узле NomadNet&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;LXMF-адрес бота: &lt;code&gt;777777779e7beceb13399c0ab0c63613&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;Команды&lt;/h2&gt;
&lt;p&gt;Писать боту можно из &lt;a href=&quot;https://github.com/markqvist/Sideband&quot;&gt;Sideband&lt;/a&gt;, Columba, &lt;a href=&quot;https://github.com/liamcottle/reticulum-meshchat&quot;&gt;MeshChat&lt;/a&gt; или NomadNet (регистр не важен, &lt;code&gt;/&lt;/code&gt; в начале тоже можно):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;подписаться&lt;/code&gt; (&lt;code&gt;sub&lt;/code&gt;, &lt;code&gt;start&lt;/code&gt;) — присылать новые посты;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;отписаться&lt;/code&gt; (&lt;code&gt;unsub&lt;/code&gt;, &lt;code&gt;stop&lt;/code&gt;) — перестать присылать;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;последние&lt;/code&gt; (&lt;code&gt;last&lt;/code&gt;) — свежие посты без подписки.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Стек&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Язык&lt;/strong&gt;: &lt;a href=&quot;https://www.python.org/&quot;&gt;Python 3.13&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Сеть&lt;/strong&gt;: &lt;a href=&quot;https://github.com/markqvist/Reticulum&quot;&gt;RNS&lt;/a&gt; + &lt;a href=&quot;https://github.com/markqvist/LXMF&quot;&gt;LXMF&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;База данных&lt;/strong&gt;: &lt;a href=&quot;https://www.sqlite.org/&quot;&gt;SQLite&lt;/a&gt; (подписчики и уже разосланные посты)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Деплой&lt;/strong&gt;: &lt;a href=&quot;https://www.docker.com/&quot;&gt;Docker&lt;/a&gt; + Docker Compose, обновление по тегам &lt;code&gt;vX.Y.Z&lt;/code&gt; через &lt;code&gt;update.sh&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Как это устроено&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Новые посты&lt;/strong&gt; бот берёт из &lt;a href=&quot;https://gluek.info/rss.xml&quot;&gt;RSS-ленты&lt;/a&gt;. При первом запуске он только запоминает уже существующие посты, чтобы не засыпать подписчиков всем архивом.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Сообщение&lt;/strong&gt; содержит ссылки на пост в зеркале NomadNet и на сайте.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Доставка&lt;/strong&gt; сначала напрямую, а если подписчик не в сети — через propagation node: клиент заберёт сообщение при следующей синхронизации.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Не ретранслятор&lt;/strong&gt;: Reticulum у бота запущен с &lt;code&gt;enable_transport = No&lt;/code&gt;, чужой трафик через сервер не ходит.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Адрес&lt;/strong&gt; подобран с префиксом &lt;code&gt;77777777&lt;/code&gt;, ключ лежит только на сервере.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Исходный код&lt;/h2&gt;
&lt;p&gt;Репозиторий открыт на GitHub: &lt;a href=&quot;https://github.com/mrgluek/lxmf_publish&quot;&gt;mrgluek/lxmf_publish&lt;/a&gt;, зеркало на моём сервере: &lt;a href=&quot;https://git.gluek.info/gluek/lxmf_publish&quot;&gt;git.gluek.info/gluek/lxmf_publish&lt;/a&gt;. Лицензия MIT.&lt;/p&gt;
</content:encoded></item><item><title>upgrades.syncthing.gluek.info</title><link>https://gluek.info/projects/upgrades-syncthing-gluek-info/</link><guid isPermaLink="true">https://gluek.info/projects/upgrades-syncthing-gluek-info/</guid><description>Зеркало обновлений Syncthing для обхода блокировки release.syncthingcdn.net в России</description><pubDate>Thu, 22 Oct 2026 22:05:00 GMT</pubDate><content:encoded>&lt;p&gt;Зеркало сервера обновлений &lt;strong&gt;&lt;a href=&quot;https://syncthing.net/&quot;&gt;Syncthing&lt;/a&gt;&lt;/strong&gt;. В России заблокирован &lt;code&gt;release.syncthingcdn.net&lt;/code&gt; (&lt;a href=&quot;https://cheburcheck.ru/check?target=https://release.syncthingcdn.net/&quot;&gt;проверка на cheburcheck&lt;/a&gt;), откуда Syncthing скачивает новые версии, поэтому автообновление перестало работать.&lt;/p&gt;
&lt;h2&gt;Как пользоваться&lt;/h2&gt;
&lt;p&gt;В веб-интерфейсе Syncthing открыть &lt;em&gt;Действия → Настройки → Дополнительно → Options&lt;/em&gt; и указать в поле &lt;strong&gt;Releases URL&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://upgrades.syncthing.gluek.info/meta.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;После этого Syncthing будет проверять и скачивать обновления через зеркало.&lt;/p&gt;
&lt;h2&gt;Это безопасно?&lt;/h2&gt;
&lt;p&gt;Да. Каждый архив релиза содержит файл &lt;code&gt;release.sig&lt;/code&gt;, и Syncthing проверяет его ключом, встроенным в сам бинарник (&lt;a href=&quot;https://github.com/syncthing/syncthing/blob/main/lib/upgrade/upgrade_supported.go&quot;&gt;lib/upgrade&lt;/a&gt;). Если зеркало отдаст изменённый файл, подпись не сойдётся и обновление просто не установится.&lt;/p&gt;
&lt;h2&gt;Как устроено&lt;/h2&gt;
&lt;p&gt;Syncthing сначала скачивает &lt;code&gt;meta.json&lt;/code&gt; со списком релизов, а в нём лежат прямые ссылки на архивы в &lt;code&gt;release.syncthingcdn.net&lt;/code&gt;. Значит, мало проксировать сам &lt;code&gt;meta.json&lt;/code&gt;, нужно ещё переписать в нём ссылки.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Скрипт по cron раз в час скачивает оригинальный &lt;code&gt;meta.json&lt;/code&gt; и заменяет адрес CDN на адрес зеркала:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/sh
set -eu
dir=/path/to/folder/syncthing
mkdir -p &quot;$dir&quot;
tmp=$(mktemp &quot;$dir/.meta.XXXXXX&quot;)
trap &quot;rm -f $tmp&quot; EXIT
curl -fsS -m 60 https://upgrades.syncthing.net/meta.json \
  | sed &quot;s#https://release.syncthingcdn.net/#https://upgrades.syncthing.gluek.info/#g&quot; &amp;gt; &quot;$tmp&quot;
grep -q &quot;\&quot;tag_name\&quot;&quot; &quot;$tmp&quot;
chmod 644 &quot;$tmp&quot;
mv &quot;$tmp&quot; &quot;$dir/meta.json&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;https://caddyserver.com/&quot;&gt;Caddy&lt;/a&gt; отдаёт этот файл как статику, а все остальные запросы проксирует на CDN:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;upgrades.syncthing.gluek.info {
	handle /meta.json {
		root * /path/to/folder/syncthing
		file_server
	}
	handle {
		reverse_proxy https://release.syncthingcdn.net {
			header_up Host release.syncthingcdn.net
		}
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Переписывать ссылки на лету можно было бы плагином &lt;code&gt;replace-response&lt;/code&gt;, но его нет в стандартной сборке Caddy, и пересобирать сервер ради одного зеркала не хотелось. Скрипт по cron проще.&lt;/p&gt;
</content:encoded></item><item><title>Срочно надо обновить ВСЁ</title><link>https://gluek.info/blog/srochno-nado-obnovit-vsyo/</link><guid isPermaLink="true">https://gluek.info/blog/srochno-nado-obnovit-vsyo/</guid><description>&gt; 17 октября 2026 года проблему обнаружил независимый ИБ-исследователь, известный под ником Wayne, который изучал публичные данные сертификатов. В...</description><pubDate>Wed, 21 Oct 2026 19:19:55 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;17 октября 2026 года проблему обнаружил независимый ИБ-исследователь, известный под ником Wayne, который изучал публичные данные сертификатов. В своем уведомлении об инциденте специалист сообщил, что все восемь проблемных ключей были созданы в апреле 2025 года для эллиптической кривой P-384, где приватный ключ должен случайно выбираться из пространства примерно в 2^384 возможных значений.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;Однако в случае eMudhra этими секретными значениями оказались числа 4, 56, 66, 115, 155, 156, 230 и 237. То есть все ключи фактически умещались в восемь бит, и атакующему достаточно было перебрать всего 256 возможных значений.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a href=&quot;https://emudhra.we-have-a-problem.com/&quot;&gt;https://emudhra.we-have-a-problem.com/&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Команда PHP готовит исправления сразу для двух критических уязвимостей, обе с оценкой 9,2 из 10 по CVSS 4.0, а релиз назначен на сегодня, 21 октября, в 14:00 UTC (16:00 в Амстердаме, 17:00 в Москве):&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a href=&quot;https://www.php.net/index.php#2026-10-20-1&quot;&gt;https://www.php.net/index.php#2026-10-20-1&lt;/a&gt;&lt;/p&gt;
</content:encoded></item><item><title>Bedrud vs Jitsi: сравнение self-hosted сервисов видеозвонков</title><link>https://gluek.info/blog/bedrud-vs-jitsi/</link><guid isPermaLink="true">https://gluek.info/blog/bedrud-vs-jitsi/</guid><description>Сравниваю Jitsi Meet и Bedrud на LiveKit: установка, ресурсы в простое и на звонках из 5, 10 и 20 человек, устойчивость к блокировкам.</description><pubDate>Tue, 20 Oct 2026 19:55:00 GMT</pubDate><content:encoded>&lt;p&gt;На моём сервере теперь работают два сервиса видеозвонков: &lt;a href=&quot;https://jitsi.org/&quot;&gt;Jitsi Meet&lt;/a&gt; на &lt;code&gt;meet.gluek.info&lt;/code&gt; и &lt;a href=&quot;https://gluek.info/projects/call-gluek-info/&quot;&gt;Bedrud&lt;/a&gt; на &lt;code&gt;call.gluek.info&lt;/code&gt;. Оба устроены одинаково, по схеме &lt;a href=&quot;https://bloggeek.me/webrtcglossary/sfu/&quot;&gt;SFU&lt;/a&gt; (Selective Forwarding Unit, «избирательный пересыльщик»): каждый участник отправляет своё видео на сервер один раз, а сервер, ничего не перекодируя, раздаёт его остальным, причём каждому в том качестве, которое тому нужно. Разница в том, что вокруг этого медиасервера.&lt;/p&gt;
&lt;h2&gt;Из чего состоят&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Jitsi Meet (docker-jitsi-meet)&lt;/th&gt;
&lt;th&gt;Bedrud + LiveKit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Контейнеры&lt;/td&gt;
&lt;td&gt;4: web (nginx), Prosody (XMPP), Jicofo, JVB&lt;/td&gt;
&lt;td&gt;2: Bedrud и LiveKit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Медиасервер&lt;/td&gt;
&lt;td&gt;JVB, Java&lt;/td&gt;
&lt;td&gt;LiveKit, Go&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Сигнализация&lt;/td&gt;
&lt;td&gt;XMPP через Prosody, Jicofo управляет конференциями&lt;/td&gt;
&lt;td&gt;HTTP API Bedrud и WebSocket LiveKit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Размер образов&lt;/td&gt;
&lt;td&gt;≈ 3,1 ГБ (JVB 1,2 ГБ, Jicofo 1 ГБ, web 570 МБ, Prosody 312 МБ)&lt;/td&gt;
&lt;td&gt;≈ 350 МБ (LiveKit 140 МБ, Bedrud 206 МБ)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Память в простое&lt;/td&gt;
&lt;td&gt;≈ 460 МБ (JVB 300, Jicofo 100, Prosody 33, web 11)&lt;/td&gt;
&lt;td&gt;≈ 50–90 МБ (Bedrud 10–50, LiveKit 40)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Порты наружу&lt;/td&gt;
&lt;td&gt;443 через прокси + UDP 10000&lt;/td&gt;
&lt;td&gt;443 через прокси + UDP 7882 и TCP 7881&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;База данных&lt;/td&gt;
&lt;td&gt;отдельной нет (Prosody хранит данные в файлах)&lt;/td&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Память я смотрел через &lt;code&gt;docker stats&lt;/code&gt; на одном и том же сервере в простое. У JVB по умолчанию ещё и большой лимит: я ограничил контейнер 1,5 ГБ, и Java охотно занимает то, что ей дают.&lt;/p&gt;
&lt;p&gt;Важная оговорка: LiveKit у меня уже был для звонков в Matrix, поэтому реальная цена Bedrud для меня — это один контейнер на 50 МБ. Если ставить его с нуля, Bedrud умеет запускать встроенный LiveKit сам, и это всё равно один процесс.&lt;/p&gt;
&lt;h2&gt;Установка и настройка&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Jitsi&lt;/strong&gt; ставится по официальной инструкции &lt;a href=&quot;https://github.com/jitsi/docker-jitsi-meet&quot;&gt;docker-jitsi-meet&lt;/a&gt;: скачать релиз, скопировать &lt;code&gt;env.example&lt;/code&gt; в &lt;code&gt;.env&lt;/code&gt;, сгенерировать пароли скриптом &lt;code&gt;gen-passwords.sh&lt;/code&gt;, указать &lt;code&gt;PUBLIC_URL&lt;/code&gt;, открыть UDP-порт для JVB. У меня в &lt;code&gt;.env&lt;/code&gt; в итоге 15 непустых строк, остальное — значения по умолчанию. Подробная документация, огромное сообщество, ответы на любые вопросы гуглятся.&lt;/p&gt;
&lt;p&gt;Проблем с установкой Jitsi у меня не было. Большой плюс — готовые клиенты: в мобильном приложении Jitsi Meet достаточно поменять адрес сервера на свой, и всё работает. К Jitsi привыкли и мои собеседники, и в целом он работает неплохо.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bedrud&lt;/strong&gt; — один бинарник или один контейнер с &lt;code&gt;config.yaml&lt;/code&gt; на три десятка строк. Но проект молодой (версия 0.13), документация местами расходится с реальностью: образа нет на ghcr, обязательный параметр &lt;code&gt;tokenDuration&lt;/code&gt; выглядит необязательным, и без него вход просто не работает. Всё это я разобрал на &lt;a href=&quot;https://gluek.info/projects/call-gluek-info/&quot;&gt;странице проекта&lt;/a&gt;, а по найденным багам написал issue. Самый неприятный для повседневной работы: гости не могут повторно зайти по ссылке в комнату, где звонок уже закончился (подробности ниже). Зато интерфейс администратора, лимиты и пользователи есть из коробки, а в Jitsi для этого пришлось бы настраивать авторизацию в Prosody.&lt;/p&gt;
&lt;h2&gt;Что умеют&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Jitsi Meet&lt;/th&gt;
&lt;th&gt;Bedrud&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Создать звонок без аккаунта&lt;/td&gt;
&lt;td&gt;да, по умолчанию&lt;/td&gt;
&lt;td&gt;нет, гости только заходят по ссылке&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Аккаунты, админка, лимиты&lt;/td&gt;
&lt;td&gt;через Prosody/JWT, без веб-админки&lt;/td&gt;
&lt;td&gt;встроены: пользователи, passkey, OAuth, лимиты&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Сквозное шифрование&lt;/td&gt;
&lt;td&gt;есть (Insertable Streams)&lt;/td&gt;
&lt;td&gt;есть флаг E2E в комнате&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Комната ожидания&lt;/td&gt;
&lt;td&gt;лобби&lt;/td&gt;
&lt;td&gt;Gate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Запись&lt;/td&gt;
&lt;td&gt;через Jibri (ещё контейнер с Chrome)&lt;/td&gt;
&lt;td&gt;в разработке&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Мобильные приложения&lt;/td&gt;
&lt;td&gt;Jitsi Meet в Google Play и App Store&lt;/td&gt;
&lt;td&gt;обещаны, но не опубликованы: в Google Play страницы нет, в релизах только десктоп (на Mac — неподписанный бинарник в &lt;code&gt;.dmg&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Зрелость&lt;/td&gt;
&lt;td&gt;с 2013 года, огромное сообщество&lt;/td&gt;
&lt;td&gt;0.x, небольшой проект: последний коммит 26 сентября, приглашение в Discord на сайте недействительно&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Нагрузка на 5, 10 и 20 человек&lt;/h2&gt;
&lt;h3&gt;Как мерил&lt;/h3&gt;
&lt;p&gt;Участников изображали headless-браузеры Chromium через &lt;a href=&quot;https://playwright.dev/&quot;&gt;Playwright&lt;/a&gt; в Docker на моём MacBook. Каждый бот заходит в комнату через обычный веб-интерфейс, как живой человек, поэтому сравниваются не только серверы, но и то, как их веб-клиенты просят видео.&lt;/p&gt;
&lt;p&gt;Сценарий как на обычной встрече, где камеры у всех одновременно не включены:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;1 «презентующий»&lt;/strong&gt;: камера показывает медленно меняющийся слайд 720p (вместо демонстрации экрана);&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;2 «говорящих»&lt;/strong&gt;: камера 720p с подвижной тестовой картинкой и звук;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;остальные слушают&lt;/strong&gt;: камера и микрофон выключены.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Боты заходят по одному, после этого 3 минуты идёт звонок, и в это время на сервере каждые 3–5 секунд пишутся &lt;code&gt;docker stats&lt;/code&gt; контейнеров и счётчики сетевого интерфейса. В таблице средние значения за эти 3 минуты. CPU указан в процентах одного ядра: на сервере их 5. Трафик — это прирост относительно фона до начала теста. В Jitsi я учитываю все четыре контейнера, в Bedrud — Bedrud и LiveKit.&lt;/p&gt;
&lt;h3&gt;Результаты&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Jitsi, 5&lt;/th&gt;
&lt;th&gt;Jitsi, 10&lt;/th&gt;
&lt;th&gt;Jitsi, 20&lt;/th&gt;
&lt;th&gt;Bedrud, 5&lt;/th&gt;
&lt;th&gt;Bedrud, 10&lt;/th&gt;
&lt;th&gt;Bedrud, 20&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU сервера, % ядра, среднее&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU сервера, % ядра, пик&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;83&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Память, МБ&lt;/td&gt;
&lt;td&gt;491&lt;/td&gt;
&lt;td&gt;550&lt;/td&gt;
&lt;td&gt;586&lt;/td&gt;
&lt;td&gt;134&lt;/td&gt;
&lt;td&gt;175&lt;/td&gt;
&lt;td&gt;248&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Отдаёт, Мбит/с&lt;/td&gt;
&lt;td&gt;1,3&lt;/td&gt;
&lt;td&gt;3,4&lt;/td&gt;
&lt;td&gt;7,3&lt;/td&gt;
&lt;td&gt;14,9&lt;/td&gt;
&lt;td&gt;33,0&lt;/td&gt;
&lt;td&gt;60,5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Принимает, Мбит/с&lt;/td&gt;
&lt;td&gt;1,0&lt;/td&gt;
&lt;td&gt;0,6&lt;/td&gt;
&lt;td&gt;0,5&lt;/td&gt;
&lt;td&gt;6,4&lt;/td&gt;
&lt;td&gt;6,5&lt;/td&gt;
&lt;td&gt;7,0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Почти вся нагрузка Jitsi приходится на JVB (18 → 45% ядра, 350 → 440 МБ), а Jicofo, Prosody и web на звонок почти не реагируют. У Bedrud так же: работает LiveKit (17 → 43% ядра, 80 → 140 МБ), а сам Bedrud тратит 0,1–0,2% CPU.&lt;/p&gt;
&lt;h3&gt;Что из этого следует&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Процессор одинаковый.&lt;/strong&gt; На одной и той же встрече JVB и LiveKit берут почти одинаковую долю ядра: около 45% на 20 человек. На пятиядерном сервере это не проблема ни для одного из них.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Память у Bedrud в 2,5–3,5 раза меньше&lt;/strong&gt;: 134–248 МБ против 490–590 МБ. Разница почти целиком в JVM: JVB с Jicofo занимают 450–540 МБ ещё до того, как начинают что-то пересылать.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Трафик отличается в 8 раз, но это разница не серверов, а клиентов.&lt;/strong&gt; Веб-клиент Jitsi в режиме плиток просит у отправителей только нижний слой качества: три камеры вместе присылают на сервер меньше 1 Мбит/с, то есть по 200–300 кбит/с на камеру. Клиент Bedrud (LiveKit) запрашивает видео под размер плитки, а плитки при трёх камерах большие, и каждый отправитель шлёт около 2 Мбит/с. Картинка в Bedrud заметно лучше, но и каналу сервера это обходится дороже: 60 Мбит/с на 20 человек против 7 у Jitsi. Если пересчитать на мегабит пересылаемого видео, LiveKit тратит процессора в разы меньше.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Оговорка про 20 человек в Bedrud:&lt;/strong&gt; все 20 ботов жили на одном Mac за DSL на 65 Мбит/с, а сервер отдавал им 60 Мбит/с. Канал был почти забит, а Docker на Mac занимал 4,5 ядра на декодировании видео. Возможно, без этого ограничения трафик Bedrud был бы ещё выше.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Своп на сервере&lt;/strong&gt; во время теста не рос (около 2,8 ГБ, как и до теста), так что ни один из серверов не вытеснил другие сервисы.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Чего этот тест не показывает&lt;/h3&gt;
&lt;p&gt;Это один прогон на каждую точку, с синтетическим видео и с одной машины-клиента. Он хорошо сравнивает два сервера между собой на одной и той же встрече, но не говорит, сколько людей выдержит сервер. Для этого нужно гонять ботов с нескольких машин и с хорошим каналом.&lt;/p&gt;
&lt;p&gt;Заодно тест выловил ещё один баг Bedrud: «постоянная» комната выключается после окончания звонка, и гости по старой ссылке в неё больше не попадут, пока хозяин её не пересоздаст (&lt;a href=&quot;https://github.com/themadorg/bedrud/issues/212&quot;&gt;issue #212&lt;/a&gt;). Сначала я принял за баг и зависание ботов на «connecting», но его вызвал я сам, когда между прогонами включал комнату прямо в базе, а в LiveKit её к тому моменту уже не было.&lt;/p&gt;
&lt;h2&gt;Блокировки: какой проще заблокировать&lt;/h2&gt;
&lt;p&gt;Оба сервиса для браузера выглядят почти одинаково: страница и сигнализация идут по HTTPS на 443-й порт, а звук и видео — по WebRTC, то есть DTLS и SRTP поверх UDP. Сам WebRTC по трафику опознаётся легко: у STUN-пакетов фиксированный «magic cookie», у DTLS характерное рукопожатие. Поэтому если цензор решит резать WebRTC целиком, пострадают оба, и любой другой сервис звонков в браузере тоже. Содержимое при этом зашифровано, а адрес сервера (&lt;code&gt;meet.gluek.info&lt;/code&gt; или &lt;code&gt;call.gluek.info&lt;/code&gt;) виден только в SNI при подключении к сайту.&lt;/p&gt;
&lt;p&gt;Различаются они тем, что будет, если заблокировать конкретный порт или UDP вообще:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Jitsi&lt;/strong&gt; шлёт медиа через один порт UDP 10000. Это порт JVB по умолчанию, и правило «резать UDP 10000» пишется в одну строку. Поэтому после публикации поста я подключил Jitsi к TURN-серверу &lt;a href=&quot;https://github.com/coturn/coturn&quot;&gt;coturn&lt;/a&gt;, который уже стоял на сервере для другого сервиса. Если UDP не проходит, клиент идёт через TURN по TCP 3478, а если закрыт и он, то через TURN по TLS на 443-м порту (&lt;code&gt;turn.gluek.info&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LiveKit&lt;/strong&gt; принимает медиа на UDP 7882, а если UDP не проходит, клиент сам переключается на TCP 7881 (ICE-TCP). Качество по TCP хуже, но звонок состоится. Порты тоже стандартные для LiveKit, так что опознать его по ним так же легко.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Сигнализация&lt;/strong&gt; у Jitsi идёт через XMPP по WebSocket, у Bedrud — через WebSocket LiveKit. Обе спрятаны внутри TLS на 443-м порту и снаружи от обычного HTTPS не отличаются.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IP-адрес&lt;/strong&gt; у обоих один и тот же — адрес моего VPS. Если заблокируют его, перестанет работать всё сразу, включая сайт.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Итого: теперь у обоих есть путь, который переживает блокировку всего, кроме 443-го порта. TURN по TLS на 443-м порту снаружи выглядит как обычное HTTPS-соединение с &lt;code&gt;turn.gluek.info&lt;/code&gt;, и отличить его можно разве что по характеру трафика. Порт 443 на сервере занят Caddy, поэтому я поставил сборку Caddy с модулем &lt;a href=&quot;https://github.com/mholt/caddy-l4&quot;&gt;layer4&lt;/a&gt;: она смотрит на SNI в начале TLS-соединения и отдаёт соединения с &lt;code&gt;turn.gluek.info&lt;/code&gt; в coturn, а всё остальное — сайтам. Тот же TURN я отдал и LiveKit: если не проходят ни UDP 7882, ни TCP 7881, клиенты Bedrud и Element Call тоже уходят на &lt;code&gt;turn.gluek.info:443&lt;/code&gt;. Путь через TLS я проверил тестовыми соединениями, а настоящий звонок из сети, где открыт только 443-й порт, ещё не пробовал.&lt;/p&gt;
&lt;h2&gt;Итог&lt;/h2&gt;
&lt;p&gt;Если LiveKit на сервере уже есть, Bedrud почти бесплатен: два контейнера, в 2,5–3,5 раза меньше памяти, чем у Jitsi, при том же процессоре и с более качественной картинкой. Но проект сырой: за один вечер я нашёл четыре бага (ссылка на комнату, например, живёт ровно один звонок), а мобильных клиентов, которые обещает сайт, пока нет.&lt;/p&gt;
&lt;p&gt;Я оставляю оба. Никогда не знаешь, какой из них в нужный момент сработает лучше: однажды Jitsi не запустился на холодную и подвёл меня, а второй сервис на том же сервере стоит почти ничего.&lt;/p&gt;
</content:encoded></item><item><title>Зеркала gluek.info в i2p и tor</title><link>https://gluek.info/blog/zerkala-gluek-info-v-i2p-i-tor/</link><guid isPermaLink="true">https://gluek.info/blog/zerkala-gluek-info-v-i2p-i-tor/</guid><description>Данный сайт также доступен по следующим адресам: http://glueky6orepqda262pgrwfrcvwbzpyzrcn5svgyfqin5t4vfb54q.b32.i2p http://gluektoro4irgilbx5n3lef...</description><pubDate>Tue, 20 Oct 2026 00:17:00 GMT</pubDate><content:encoded>&lt;p&gt;Данный сайт также доступен по следующим адресам:&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;http://glueky6orepqda262pgrwfrcvwbzpyzrcn5svgyfqin5t4vfb54q.b32.i2p&quot;&gt;http://glueky6orepqda262pgrwfrcvwbzpyzrcn5svgyfqin5t4vfb54q.b32.i2p&lt;/a&gt;
&lt;a href=&quot;http://gluektoro4irgilbx5n3lefhofqeeu3acys7a2hprk2u3o65nvzhmjyd.onion&quot;&gt;http://gluektoro4irgilbx5n3lefhofqeeu3acys7a2hprk2u3o65nvzhmjyd.onion&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Если будут силы, потом напишу в Проекты про подбор имени и настройку, но там вроде ничего сложного.&lt;/p&gt;
</content:encoded></item><item><title>call.gluek.info</title><link>https://gluek.info/projects/call-gluek-info/</link><guid isPermaLink="true">https://gluek.info/projects/call-gluek-info/</guid><description>Видеозвонки на Bedrud поверх того же LiveKit, что уже работает для звонков в Matrix</description><pubDate>Tue, 20 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Видеозвонки в браузере на &lt;strong&gt;&lt;a href=&quot;https://bedrud.org/&quot;&gt;Bedrud&lt;/a&gt;&lt;/strong&gt;: комната по ссылке, гости без регистрации. Сайт обещает ещё клиенты для Android, iOS и десктопа, но на деле их почти нет: страница в Google Play не открывается, Homebrew-тапа не существует, а в релизах на GitHub есть только десктопный клиент, причём для Mac это &lt;code&gt;.dmg&lt;/code&gt; с голым неподписанным исполняемым файлом вместо приложения. Исходники Android- и iOS-клиентов в репозитории лежат, но готовых сборок нет. Главная особенность моей установки в том, что Bedrud не поднимает свой медиасервер, а использует &lt;strong&gt;&lt;a href=&quot;https://livekit.io/&quot;&gt;LiveKit&lt;/a&gt;&lt;/strong&gt; на &lt;code&gt;rtc.gluek.info&lt;/code&gt;, который уже стоял для звонков в &lt;a href=&quot;https://gluek.info/projects/matrix-gluek-info/&quot;&gt;Matrix&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Зачем, если есть Jitsi&lt;/h2&gt;
&lt;p&gt;На &lt;code&gt;meet.gluek.info&lt;/code&gt; у меня с июня работает &lt;a href=&quot;https://jitsi.org/&quot;&gt;Jitsi Meet&lt;/a&gt;, и он никуда не делся. Но это четыре контейнера (веб, XMPP-сервер Prosody, Jicofo и видеомост JVB на Java), которые в простое занимают около 460 МБ памяти, а образы весят больше 3 ГБ. На сервере, где уже работают несколько десятков контейнеров и память на счету, это заметно.&lt;/p&gt;
&lt;p&gt;Когда для Element Call пришлось поднять LiveKit, оказалось, что на сервере теперь есть второй, куда более лёгкий медиасервер. LiveKit — это SFU на Go, тот же класс, что и JVB, только без XMPP вокруг. Оставалось найти к нему веб-интерфейс «комната по ссылке», и Bedrud подошёл: он и так построен на LiveKit и умеет работать с внешним сервером.&lt;/p&gt;
&lt;p&gt;Сравнение по ресурсам и настройке — в отдельном посте &lt;a href=&quot;https://gluek.info/blog/bedrud-vs-jitsi/&quot;&gt;Bedrud vs Jitsi&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Как это устроено&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;браузер ──https──▶ Caddy ──▶ bedrud:8090      (интерфейс, API, комнаты, пользователи)
   │
   └──wss/udp──▶ rtc.gluek.info ──▶ livekit:7880   (медиа: UDP 7882 / TCP 7881,
                                                     запасной путь: TURN/TLS turn.gluek.info:443)
                                    ▲    │
             создание комнат, токены ┘    └─ вебхуки ──▶ bedrud и lk-jwt (Matrix)
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Bedrud&lt;/strong&gt; отвечает за всё, кроме медиа: пользователи, комнаты, чат, права. Браузеру он выдаёт токен LiveKit и адрес &lt;code&gt;wss://rtc.gluek.info&lt;/code&gt;, а дальше звук и видео идут напрямую в LiveKit&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LiveKit&lt;/strong&gt; один на всех: его же использует &lt;a href=&quot;https://github.com/element-hq/lk-jwt-service&quot;&gt;lk-jwt-service&lt;/a&gt; для звонков в Element X. Новых портов открывать не пришлось, всё медиа идёт через тот же UDP 7882. Если у клиента закрыты и UDP, и TCP 7881, медиа идёт через TURN-сервер coturn по TLS на 443-м порту (подробнее — в разделе про блокировки в &lt;a href=&quot;https://gluek.info/blog/bedrud-vs-jitsi/&quot;&gt;посте&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;В простое Bedrud занимает около 10 МБ памяти, после звонков около 50 МБ. LiveKit — 40–80 МБ, и эту память я уже тратил ради Matrix&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Установка&lt;/h2&gt;
&lt;p&gt;Bedrud ставится одним бинарником или скриптом &lt;code&gt;curl | bash&lt;/code&gt;, но у меня всё в Docker за общим Caddy, поэтому я собрал установку руками.&lt;/p&gt;
&lt;h3&gt;Образ&lt;/h3&gt;
&lt;p&gt;В документации указан образ &lt;code&gt;ghcr.io/themadorg/bedrud&lt;/code&gt;, но на момент установки скачать его без авторизации нельзя (&lt;code&gt;unauthorized&lt;/code&gt;). Docker-образы лежат в &lt;a href=&quot;https://github.com/themadorg/bedrud/releases&quot;&gt;релизах на GitHub&lt;/a&gt; архивами, их нужно загрузить самому:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;B=https://github.com/themadorg/bedrud/releases/download/v0.13.0
curl -fsSLO $B/SHA256SUMS
curl -fsSLO $B/bedrud-docker-alpine-amd64.tar.gz
grep alpine-amd64 SHA256SUMS &amp;amp;&amp;amp; sha256sum bedrud-docker-alpine-amd64.tar.gz
docker load -i bedrud-docker-alpine-amd64.tar.gz   # ghcr.io/themadorg/bedrud:0.13.0-alpine
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Обновление — то же самое с новой версией и сменой тега в compose.&lt;/p&gt;
&lt;h3&gt;Конфиг&lt;/h3&gt;
&lt;p&gt;Главное — &lt;code&gt;livekit.external: true&lt;/code&gt;: тогда Bedrud не запускает встроенный LiveKit и не проксирует его через себя.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server:
  port: &quot;8090&quot;
  domain: &quot;call.gluek.info&quot;
  behindProxy: true
  trustedProxies: [&quot;172.18.0.0/16&quot;]   # Docker-сеть, где живёт Caddy
database:
  type: &quot;sqlite&quot;
  path: &quot;/var/lib/bedrud/bedrud.db&quot;
livekit:
  external: true
  host: &quot;wss://rtc.gluek.info&quot;        # куда подключаются браузеры
  internalHost: &quot;http://livekit:7880&quot; # API LiveKit внутри Docker-сети
  apiKey: &quot;lk-gluek&quot;                  # тот же ключ, что у lk-jwt (см. ниже)
  apiSecret: &quot;…&quot;
auth:
  jwtSecret: &quot;…&quot;                      # openssl rand -hex 32
  sessionSecret: &quot;…&quot;
  frontendURL: &quot;https://call.gluek.info&quot;
  tokenDuration: 24                   # обязательно, см. подводные камни
cors:
  allowedOrigins: &quot;https://call.gluek.info&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Compose — один сервис в общей сети с Caddy и LiveKit, без опубликованных портов:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  bedrud:
    image: ghcr.io/themadorg/bedrud:0.13.0-alpine
    container_name: bedrud
    command: [&quot;run&quot;, &quot;--config&quot;, &quot;/etc/bedrud/config.yaml&quot;]
    mem_limit: 256m
    restart: unless-stopped
    volumes:
      - ./config.yaml:/etc/bedrud/config.yaml:ro
      - ./data:/var/lib/bedrud
    networks: [caddy]
networks:
  caddy:
    external: true   # общая сеть с Caddy и LiveKit
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LiveKit: общий ключ и второй вебхук&lt;/h3&gt;
&lt;p&gt;Bedrud узнаёт от LiveKit, что участник вышел или комната закрылась, через вебхуки. В конфиге LiveKit достаточно добавить второй адрес:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;webhook:
  api_key: lk-gluek
  urls:
    - http://lk-jwt:8080/sfu_webhook            # Matrix
    - http://bedrud:8090/api/livekit/webhook    # Bedrud
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Тонкость в том, что у LiveKit &lt;strong&gt;один&lt;/strong&gt; &lt;code&gt;webhook.api_key&lt;/code&gt; на все адреса: каждый вебхук подписывается этим ключом. А Bedrud проверяет подпись своим &lt;code&gt;apiKey&lt;/code&gt;. Поэтому завести Bedrud отдельную пару ключей не получится: ему нужен тот же ключ, что и lk-jwt. На своём сервере это не страшно, но Bedrud при этом может управлять и комнатами Element Call.&lt;/p&gt;
&lt;p&gt;Комнату в LiveKit Bedrud создаёт сам через API, поэтому &lt;code&gt;room.auto_create: false&lt;/code&gt;, который стоит в LiveKit ради Element Call (чтобы комнаты на моём SFU не могли создавать пользователи Matrix с других серверов), ему не мешает.&lt;/p&gt;
&lt;h3&gt;Caddy&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;call.gluek.info {
	reverse_proxy bedrud:8090
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;После этого нужно зарегистрироваться (первый аккаунт я сделал через passkey) и выдать себе права админа:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec bedrud bedrud --config /etc/bedrud/config.yaml user promote --email you@example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Подводные камни&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;tokenDuration&lt;/code&gt; без значения по умолчанию&lt;/h3&gt;
&lt;p&gt;Если не указать &lt;code&gt;auth.tokenDuration&lt;/code&gt;, он равен нулю, и каждый токен входа истекает в момент выдачи (&lt;code&gt;exp == iat&lt;/code&gt;). Вход проходит, но на любое действие интерфейс отвечает «Invalid token», через несколько секунд выкидывает на страницу входа, а при закрытой регистрации ещё и пишет, что регистрация выключена. Подпись токена при этом верная, так что искать проблему в секретах или прокси бесполезно. В примере конфига в документации &lt;code&gt;tokenDuration: 24&lt;/code&gt; есть, но выглядит как необязательный. Написал об этом &lt;a href=&quot;https://github.com/themadorg/bedrud/issues/210&quot;&gt;issue #210&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;Вебхуки, которые «не проходят подпись»&lt;/h3&gt;
&lt;p&gt;В логе Bedrud на каждый вход в комнату и публикацию трека пишется &lt;code&gt;LiveKit webhook: invalid signature&lt;/code&gt;. На самом деле подпись в порядке: Bedrud разбирает JSON от LiveKit обычным &lt;code&gt;encoding/json&lt;/code&gt;, а тот не понимает enum-поля, которые LiveKit присылает строками. В результате событие о выходе участника теряется, и он числится в комнате, пока её не закроют. От внешнего LiveKit это не зависит, встроенный присылает то же самое. &lt;a href=&quot;https://github.com/themadorg/bedrud/issues/209&quot;&gt;Issue #209&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;Ссылка на комнату живёт один звонок&lt;/h3&gt;
&lt;p&gt;Когда из комнаты выходит последний участник, LiveKit примерно через 20 секунд удаляет её у себя и присылает событие &lt;code&gt;room_finished&lt;/code&gt;, а Bedrud по нему помечает комнату неактивной. Гость, открывший ту же ссылку позже, увидит «room is no longer active». Хозяин комнаты при входе получит предложение пересоздать её с тем же адресом. Но пересозданная комната становится &lt;strong&gt;приватной&lt;/strong&gt;, и гостей в неё опять не пустит, пока в настройках не включить Public.&lt;/p&gt;
&lt;p&gt;Галочка «постоянная комната» от этого не спасает: планировщик Bedrud постоянные комнаты не трогает, а вот обработчик &lt;code&gt;room_finished&lt;/code&gt; выключает их так же, как и остальные. Описал это в &lt;a href=&quot;https://github.com/themadorg/bedrud/issues/212&quot;&gt;issue #212&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Поначалу я решил, что дело в &lt;code&gt;auto_create: false&lt;/code&gt; у LiveKit, и даже включил его. Но оказалось, что зависание на «connecting» я вызвал сам во время &lt;a href=&quot;https://gluek.info/blog/bedrud-vs-jitsi/&quot;&gt;нагрузочного теста&lt;/a&gt;, когда вручную включил комнату в базе, а в LiveKit её уже не было. В обычной работе Bedrud создаёт комнату в LiveKit сам, так что &lt;code&gt;auto_create&lt;/code&gt; можно оставить выключенным.&lt;/p&gt;
&lt;p&gt;Единственный реальный случай, когда это мешает, — перезапуск LiveKit (например, при обновлении) посреди звонка. LiveKit держит комнаты в памяти и забывает их, не отправив &lt;code&gt;room_finished&lt;/code&gt;. Участники не могут переподключиться, пока планировщик Bedrud примерно через минуту не погасит комнату, после чего её придётся пересоздать. Так что LiveKit лучше обновлять, когда никто не звонит.&lt;/p&gt;
&lt;h3&gt;«TLS off» в админке&lt;/h3&gt;
&lt;p&gt;На странице Overview горит красный бейдж «TLS off». Он показывает, терминирует ли HTTPS сам процесс Bedrud, а у меня этим занимается Caddy. Можно игнорировать.&lt;/p&gt;
&lt;h3&gt;Сертификат и Let&apos;s Encrypt&lt;/h3&gt;
&lt;p&gt;Блок в Caddy я добавил раньше, чем DNS-запись, а в ту же ночь у Let&apos;s Encrypt ещё и был инцидент с валидацией. Сертификат в итоге выдал ZeroSSL: Caddy переключается на него сам, если в глобальных опциях указан &lt;code&gt;email&lt;/code&gt;. Без него Caddy ходит только в Let&apos;s Encrypt (а при ошибках — в его staging). Вывод на будущее: сначала DNS, потом Caddy, и &lt;code&gt;email&lt;/code&gt; в глобальном блоке лучше держать всегда.&lt;/p&gt;
&lt;h2&gt;Настройки&lt;/h2&gt;
&lt;p&gt;Регистрация открыта для всех, но с ограничениями. Гости без аккаунта могут только заходить в комнаты по ссылке, создавать комнаты им нельзя: это зашито в коде, настройки нет. Почту Bedrud не подтверждает, потому что SMTP пока не настроен.&lt;/p&gt;
&lt;p&gt;Лимиты задаются через CLI и хранятся в базе, а не в &lt;code&gt;config.yaml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;B=&quot;docker exec bedrud bedrud --config /etc/bedrud/config.yaml&quot;
$B settings set maxRoomsPerUser 3              # активных комнат на пользователя (админа не касается)
$B settings set maxParticipantsLimit 20        # участников в комнате
$B settings set maxUploadBytesPerUser 52428800 # 50 МБ файлов в чате на пользователя
$B settings set globalDiskThresholdBytes 2147483648  # 2 ГБ на все загрузки
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Последний лимит нужен потому, что файлы из чата удаляются только вместе с комнатой, когда её завершают или удаляют. Если все просто вышли, комната становится неактивной, но файлы остаются навсегда. Настройка &lt;code&gt;chatMessageTTLHours&lt;/code&gt; в админке есть, но в версии 0.13.0 ни на что не влияет. Предложил это доделать в &lt;a href=&quot;https://github.com/themadorg/bedrud/issues/211&quot;&gt;issue #211&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;При создании комнаты можно включить сквозное шифрование (E2E) и комнату ожидания (Gate), где гостей впускает хозяин.&lt;/p&gt;
&lt;h2&gt;Итог&lt;/h2&gt;
&lt;p&gt;Bedrud — молодой проект (версия 0.13, на момент установки 9 звёзд на GitHub), так что Jitsi я не выключаю. Но как второй вариант для звонков он почти ничего не стоит: при уже работающем LiveKit это один контейнер на 50 МБ и пара строк в конфигах.&lt;/p&gt;
</content:encoded></item><item><title>matrix.gluek.info</title><link>https://gluek.info/projects/matrix-gluek-info/</link><guid isPermaLink="true">https://gluek.info/projects/matrix-gluek-info/</guid><description>Личный Matrix-сервер на Tuwunel с адресом @gluek:gluek.info на собственном домене</description><pubDate>Mon, 19 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Личный сервер &lt;strong&gt;&lt;a href=&quot;https://matrix.org/&quot;&gt;Matrix&lt;/a&gt;&lt;/strong&gt; на базе &lt;strong&gt;&lt;a href=&quot;https://github.com/matrix-construct/tuwunel&quot;&gt;Tuwunel&lt;/a&gt;&lt;/strong&gt;, легковесного homeserver на Rust. Мой адрес в Matrix теперь &lt;code&gt;@gluek:gluek.info&lt;/code&gt;, написать мне можно по ссылке &lt;a href=&quot;https://matrix.to/#/@gluek:gluek.info&quot;&gt;matrix.to/#/@gluek:gluek.info&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Зачем свой сервер, если есть matrix.org&lt;/h2&gt;
&lt;p&gt;Аккаунт &lt;code&gt;@gluek:matrix.org&lt;/code&gt; я завёл довольно давно, чтобы читать несколько публичных групп, но активно им так и не пользовался.&lt;/p&gt;
&lt;p&gt;Главный довод за свой сервер: &lt;strong&gt;в Matrix нельзя перенести аккаунт&lt;/strong&gt; с одного сервера на другой. Часть адреса после двоеточия навсегда привязывает его к серверу, и если однажды условия на matrix.org перестанут устраивать, остаётся только завести новый аккаунт и заново собирать контакты и комнаты. А раз переносить почти нечего, лучше сделать это сейчас.&lt;/p&gt;
&lt;p&gt;Повод задуматься дал и сам matrix.org: для бесплатных аккаунтов там появились &lt;a href=&quot;https://matrix.org/homeserver/pricing/&quot;&gt;лимиты&lt;/a&gt;: не больше 10 МБ на вложение и 500 МБ трафика вложений за 28 дней, а в примечании честно сказано, что лимиты будут пересматриваться. Мне этого пока хватает, но это тот же сценарий, что и с &lt;a href=&quot;https://gluek.info/projects/vault-gluek-info/&quot;&gt;1Password&lt;/a&gt;: зависимость от чужой ценовой политики.&lt;/p&gt;
&lt;p&gt;При этом привязка к домену, а не к серверу, работает в мою пользу: сервер можно сколько угодно переносить между машинами и даже реализациями, а адрес &lt;code&gt;@gluek:gluek.info&lt;/code&gt; останется тем же, пока у меня есть домен.&lt;/p&gt;
&lt;p&gt;Отдельно стоит сказать про приватность: свой сервер на одного человека её почти не прибавляет. Остальные серверы федерации видят, в каких комнатах я состою и когда пишу, и на сервере с одним пользователем вся его активность — это я. На matrix.org я хотя бы терялся в толпе. Так что дело здесь в контроле над адресом и данными, а не в анонимности.&lt;/p&gt;
&lt;h2&gt;Почему Tuwunel&lt;/h2&gt;
&lt;p&gt;Эталонный сервер &lt;strong&gt;&lt;a href=&quot;https://github.com/element-hq/synapse&quot;&gt;Synapse&lt;/a&gt;&lt;/strong&gt; на Python требует PostgreSQL и заметно больше памяти, а для одного аккаунта это перебор. &lt;strong&gt;Dendrite&lt;/strong&gt; фактически в режиме поддержки. Остаются потомки conduwuit на Rust: &lt;strong&gt;Tuwunel&lt;/strong&gt; и &lt;strong&gt;&lt;a href=&quot;https://forgejo.ellis.link/continuwuation/continuwuity&quot;&gt;continuwuity&lt;/a&gt;&lt;/strong&gt;. Оба ставятся одним контейнером со встроенной RocksDB и поддерживают sliding sync для Element X.&lt;/p&gt;
&lt;p&gt;Выбрал Tuwunel: его основной спонсор — правительство Швейцарии, разработку ведут штатные сотрудники, и для сервера, который должен прожить долго, это выглядит надёжнее. continuwuity развивает сообщество, и проект тоже живой. По возможностям они сейчас близки.&lt;/p&gt;
&lt;h2&gt;Стек&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/matrix-construct/tuwunel&quot;&gt;Tuwunel&lt;/a&gt;&lt;/strong&gt; через &lt;a href=&quot;https://docs.docker.com/compose/&quot;&gt;Docker Compose&lt;/a&gt; на том же VPS, что и остальные сервисы. В простое занимает около 80 МБ памяти&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://caddyserver.com/&quot;&gt;Caddy&lt;/a&gt;&lt;/strong&gt; в роли reverse proxy (с автоматическим HTTPS от &lt;a href=&quot;https://letsencrypt.org/&quot;&gt;Let&apos;s Encrypt&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Делегирование:&lt;/strong&gt; &lt;code&gt;server_name&lt;/code&gt; — это &lt;code&gt;gluek.info&lt;/code&gt;, а сам сервер живёт на &lt;code&gt;matrix.gluek.info&lt;/code&gt;. Порт 8448 и SRV-записи не нужны, федерация идёт через 443&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Регистрация&lt;/strong&gt; закрыта: аккаунт создан по одноразовому токену, после чего регистрация выключена. Новые аккаунты я могу завести вручную через админ-комнату&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Бэкапы&lt;/strong&gt; каждую ночь: контейнер на секунду останавливается, база RocksDB копируется целиком, и с неё снимаются копии вместе с остальным сервером. Подписывающий ключ сервера лежит внутри базы, так что эта копия и есть весь сервер&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Обновления&lt;/strong&gt; вручную по уведомлению в админ-комнате: скрипт сначала делает бэкап, потом подтягивает новый образ и ждёт, пока сервер пройдёт health-check&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Клиенты:&lt;/strong&gt; &lt;a href=&quot;https://element.io/download&quot;&gt;Element Desktop&lt;/a&gt; на Mac (есть в Homebrew: &lt;code&gt;brew install --cask element&lt;/code&gt;) и &lt;a href=&quot;https://element.io/app&quot;&gt;Element X&lt;/a&gt; на Android. Возможно, ещё попробую &lt;a href=&quot;https://fluffychat.im/&quot;&gt;FluffyChat&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Сервер личный, но если мы знакомы и вам нужен аккаунт в Matrix, пишите: заведу. Если нет, можно поднять свой сервер, ориентируясь на этот разбор, или выбрать один из &lt;a href=&quot;https://servers.joinmatrix.org/&quot;&gt;публичных серверов&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;Делегирование через &lt;code&gt;.well-known&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Чтобы адрес был &lt;code&gt;@gluek:gluek.info&lt;/code&gt;, а не &lt;code&gt;@gluek:matrix.gluek.info&lt;/code&gt;, сервер и клиенты должны найти настоящий хост через два файла на основном домене: &lt;code&gt;/.well-known/matrix/server&lt;/code&gt; и &lt;code&gt;/.well-known/matrix/client&lt;/code&gt;. Tuwunel умеет отдавать их сам, если указать в конфиге:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[global]
server_name = &quot;gluek.info&quot;

[global.well_known]
client = &quot;https://matrix.gluek.info&quot;
server = &quot;matrix.gluek.info:443&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;А в Caddy достаточно прокинуть эти пути с основного сайта:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;gluek.info {
	handle /.well-known/matrix/* {
		reverse_proxy tuwunel:8008
	}
	# ... остальной сайт
}

matrix.gluek.info {
	request_body {
		max_size 100MB
	}
	reverse_proxy tuwunel:8008
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Подводный камень: двойной CORS-заголовок&lt;/h3&gt;
&lt;p&gt;По многим инструкциям для &lt;code&gt;.well-known/matrix/client&lt;/code&gt; нужно руками добавить &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt;, иначе веб-клиенты не смогут прочитать файл. Но если файлы отдаёт сам Tuwunel, он уже ставит этот заголовок, и с ручным добавлением в ответе оказываются &lt;strong&gt;два&lt;/strong&gt; одинаковых заголовка. Браузеры такой ответ отвергают как некорректный CORS, хотя &lt;code&gt;curl&lt;/code&gt; и федерационный тестер ничего подозрительного не показывают. Проверить так:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -sI https://example.com/.well-known/matrix/client | grep -ci access-control-allow-origin
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Должно быть &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Проверить федерацию целиком можно через &lt;a href=&quot;https://federationtester.matrix.org/&quot;&gt;Matrix Federation Tester&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Звонки: два несовместимых протокола&lt;/h2&gt;
&lt;p&gt;Самое неочевидное во всей истории. В Matrix сейчас &lt;strong&gt;две разные системы звонков&lt;/strong&gt;, и &lt;strong&gt;друг с другом они не соединяются&lt;/strong&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Старые звонки 1:1&lt;/strong&gt; (события &lt;code&gt;m.call.*&lt;/code&gt;) — обычный WebRTC: устройства соединяются напрямую, а если мешает NAT, идут через TURN-сервер. Их умеют Element Desktop/Web, классический Element Android и &lt;a href=&quot;https://fluffychat.im/&quot;&gt;FluffyChat&lt;/a&gt;. Групповые звонки так не сделать (каждый соединяется с каждым), поэтому в Element они годами работали через встроенный Jitsi-виджет.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MatrixRTC / &lt;a href=&quot;https://github.com/element-hq/element-call&quot;&gt;Element Call&lt;/a&gt;&lt;/strong&gt; — новая схема: все участники шлют медиа на SFU-сервер &lt;a href=&quot;https://livekit.io/&quot;&gt;LiveKit&lt;/a&gt;, а он раздаёт потоки остальным. Звонок 1:1 и групповой устроены одинаково, сквозное шифрование работает и в группах. Цена — ещё один сервер рядом с homeserver.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://element.io/app&quot;&gt;Element X&lt;/a&gt; умеет только новую схему.&lt;/strong&gt; Поэтому если позвонить из FluffyChat человеку в Element X, он звонка просто не увидит, и наоборот. Обе стороны должны пользоваться клиентами с одной и той же схемой. И отдельно: на сервере без LiveKit в Element X звонки не работают вовсе, никакой TURN тут не поможет.&lt;/p&gt;
&lt;p&gt;У себя я поднял обе:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;TURN для старых звонков:&lt;/strong&gt; &lt;a href=&quot;https://github.com/coturn/coturn&quot;&gt;coturn&lt;/a&gt;, который уже стоял на сервере для другого сервиса. Tuwunel выдаёт клиентам временные учётные данные к нему через общий секрет (&lt;code&gt;turn_uris&lt;/code&gt; и &lt;code&gt;turn_secret_file&lt;/code&gt; в конфиге). Кроме обычного порта 3478 (UDP и TCP) клиенты получают ещё &lt;code&gt;turns:turn.gluek.info:443&lt;/code&gt; — TURN по TLS на 443-м порту, на случай сетей, где открыт только HTTPS (как он сделан, ниже)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LiveKit для Element Call:&lt;/strong&gt; сам &lt;a href=&quot;https://github.com/livekit/livekit&quot;&gt;LiveKit&lt;/a&gt; и маленький &lt;a href=&quot;https://github.com/element-hq/lk-jwt-service&quot;&gt;lk-jwt-service&lt;/a&gt;, который выдаёт токены участникам Matrix, на поддомене &lt;code&gt;rtc.gluek.info&lt;/code&gt;. Клиенты узнают о нём из того же &lt;code&gt;.well-known/matrix/client&lt;/code&gt; (в Tuwunel это одна строка &lt;code&gt;livekit_url&lt;/code&gt;). Медиа идёт через один UDP-порт, так что отдельный диапазон портов не нужен. Звонок в Element X между &lt;code&gt;@gluek:gluek.info&lt;/code&gt; и моим старым аккаунтом на matrix.org работает в обе стороны, со звуком и видео. Тот же &lt;code&gt;turn.gluek.info:443&lt;/code&gt; LiveKit раздаёт клиентам как запасной путь (&lt;code&gt;rtc.turn_servers&lt;/code&gt; с тем же общим секретом), если не проходят ни UDP 7882, ни TCP 7881&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Подводный камень: TURN как дыра во внутреннюю сеть&lt;/h3&gt;
&lt;p&gt;TURN по своей природе пересылает трафик туда, куда попросит клиент. Если не запретить частные адреса, то любой, у кого есть учётные данные (а с Matrix их получает каждый залогиненный клиент), может через coturn достучаться до внутренних сервисов: баз данных в Docker-сетях, &lt;code&gt;localhost&lt;/code&gt; и всего, что не торчит наружу. Закрывается это списком &lt;code&gt;--denied-peer-ip&lt;/code&gt; для всех частных диапазонов (&lt;code&gt;10.0.0.0/8&lt;/code&gt;, &lt;code&gt;172.16.0.0/12&lt;/code&gt;, &lt;code&gt;192.168.0.0/16&lt;/code&gt;, &lt;code&gt;127.0.0.0/8&lt;/code&gt; и т.д.), а заодно &lt;code&gt;--no-tcp-relay&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Но в Docker это ломает сам TURN: в bridge-режиме ретранслятор живёт на внутреннем &lt;code&gt;172.x&lt;/code&gt;-адресе, и звонок, где обе стороны идут через один и тот же TURN, упирается в только что запрещённый диапазон. Помог перевод coturn в &lt;code&gt;network_mode: host&lt;/code&gt; с явным публичным адресом (&lt;code&gt;--listening-ip&lt;/code&gt; и &lt;code&gt;--relay-ip&lt;/code&gt;). Тогда, правда, порты перестают пробрасываться в обход файрвола, и релейный диапазон нужно открыть в UFW.&lt;/p&gt;
&lt;p&gt;Проверить можно утилитой &lt;code&gt;turnutils_uclient&lt;/code&gt; из того же образа coturn: запрос к внутреннему адресу должен получать &lt;code&gt;403&lt;/code&gt;, а обычная ретрансляция — проходить без потерь.&lt;/p&gt;
&lt;h3&gt;TURN по TLS на 443-м порту рядом с Caddy&lt;/h3&gt;
&lt;p&gt;В сетях с жёсткой фильтрацией открыт бывает только 443-й порт. TURN умеет работать и там: по TLS, так что снаружи соединение выглядит как обычный HTTPS. Но 443-й порт у меня занят Caddy, через который работают все сайты.&lt;/p&gt;
&lt;p&gt;Решил это модулем &lt;a href=&quot;https://github.com/mholt/caddy-l4&quot;&gt;caddy-l4&lt;/a&gt;, который добавляет в Caddy обработку на уровне TCP. Он смотрит на имя сервера (SNI) в начале TLS-соединения: соединения с &lt;code&gt;turn.gluek.info&lt;/code&gt; Caddy расшифровывает сам и отдаёт обычным TURN в coturn на порт 3478, а всё остальное идёт дальше, к сайтам. Свой TLS и свои сертификаты coturn при этом не нужны, сертификат для &lt;code&gt;turn.gluek.info&lt;/code&gt; получает и продлевает Caddy:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
	servers :443 {
		listener_wrappers {
			layer4 {
				@turn tls sni turn.gluek.info
				route @turn {
					tls
					proxy turn.gluek.info:3478   # coturn в host-сети, поэтому по внешнему имени, а не по имени контейнера
				}
			}
			tls
		}
	}
}

# нужен только для того, чтобы Caddy получал и продлевал сертификат;
# TLS-соединения с этим именем до HTTP-сервера не доходят
turn.gluek.info {
	respond 404
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Модуль не входит в официальный образ, поэтому Caddy теперь своя сборка:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FROM caddy:2.11.6-builder AS builder
RUN xcaddy build v2.11.6 --with github.com/mholt/caddy-l4

FROM caddy:2.11.6
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Цена — Caddy больше не обновляется сам вместе с тегом &lt;code&gt;latest&lt;/code&gt;: для новой версии нужно пересобрать образ.&lt;/p&gt;
&lt;p&gt;Раз временные логины к TURN теперь раздаёт и Jitsi, а его может открыть кто угодно, на coturn я поставил квоты: не больше ~15 Мбит/с на сессию в каждую сторону (&lt;code&gt;--max-bps&lt;/code&gt;), не больше 16 сессий на одного пользователя (&lt;code&gt;--user-quota&lt;/code&gt;) и 100 сессий всего (&lt;code&gt;--total-quota&lt;/code&gt;).&lt;/p&gt;
</content:encoded></item><item><title>botkeep.cloud</title><link>https://gluek.info/blog/botkeep-cloud/</link><guid isPermaLink="true">https://gluek.info/blog/botkeep-cloud/</guid><description>Интересный проект, который позволяет вам размещать небольших ботов (и в целом ещё несколько сервисов типа Uptime Kuma или хостинга вашего статичног...</description><pubDate>Thu, 15 Oct 2026 14:12:33 GMT</pubDate><content:encoded>&lt;p&gt;Интересный проект, который позволяет вам размещать небольших ботов (и в целом ещё несколько сервисов типа Uptime Kuma или хостинга вашего статичного сайта) бесплатно, если успеете зарегистрироваться в течение этого часа.&lt;/p&gt;
</content:encoded></item><item><title>vault.gluek.info</title><link>https://gluek.info/projects/vault-gluek-info/</link><guid isPermaLink="true">https://gluek.info/projects/vault-gluek-info/</guid><description>Собственный менеджер паролей на Vaultwarden и переезд на него из 1Password со всеми сейфами и вложениями</description><pubDate>Tue, 22 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Личный менеджер паролей на базе &lt;strong&gt;&lt;a href=&quot;https://github.com/dani-garcia/vaultwarden&quot;&gt;Vaultwarden&lt;/a&gt;&lt;/strong&gt;. Это легковесная серверная реализация API Bitwarden на Rust, с которой работают все официальные клиенты &lt;a href=&quot;https://bitwarden.com/&quot;&gt;Bitwarden&lt;/a&gt;: браузерные расширения, десктоп, мобильные приложения и CLI.&lt;/p&gt;
&lt;h2&gt;Почему ушёл с 1Password&lt;/h2&gt;
&lt;p&gt;До 1Password я долгое время пользовался &lt;a href=&quot;https://keepass.info/&quot;&gt;KeePass&lt;/a&gt;/&lt;a href=&quot;https://www.keepassx.org/&quot;&gt;KeePassX&lt;/a&gt;, а на 1Password перешёл, когда понадобились командные хранилища. Но за последнее время накопилось сразу несколько поводов уйти.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Цена.&lt;/strong&gt; Несмотря на обещание сохранить цену для ранних пользователей, 1Password объявил о повышении цены семейного плана с 27 марта 2026 года: с $59.88 до $71.88 в год. Потом, правда, отыграли назад, и для меня как для «Families Launch Special Plan» цена не изменилась. Но осадочек остался, тем более что по деньгам это сравнимо с арендой целого VPS.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Отказ от локальных сейфов.&lt;/strong&gt; Поддержку 1Password 7 убрали, а вместе с ней и работу с локальными сейфами, включая синхронизацию «по воздуху» по Wi-Fi и через Dropbox. Теперь синхронизация есть только через их облако и только по подписке.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Российская карта.&lt;/strong&gt; Когда-то я оплачивал подписку российской картой, и в какой-то момент поддержка начала волноваться: уточняли, нет ли у меня в аккаунте участников из России, Беларуси, Луганска, Донецка или Крыма. Сейчас я живу в Лондоне, но часть российских сервисов доступна только через VPN, а это повышает риск случайной блокировки аккаунта. Физическое местоположение они, надеюсь, не отслеживают… наверное.&lt;/p&gt;
&lt;p&gt;В итоге менеджер паролей, самое чувствительное, что у меня есть, оказался в зависимости от чужой ценовой политики и чужих комплаенс-процедур. Как и в случае с &lt;a href=&quot;https://gluek.info/projects/git-gluek-info/&quot;&gt;git.gluek.info&lt;/a&gt;, проще держать это у себя.&lt;/p&gt;
&lt;p&gt;Инстанс приватный: открытая регистрация выключена, аккаунты создаются только по приглашениям. Если мы знакомы и вам нужен аккаунт, пишите. Если нет, Vaultwarden несложно поставить самостоятельно или найти публичный сервер, например &lt;a href=&quot;https://vaultwarden.com/&quot;&gt;vaultwarden.com&lt;/a&gt;. Но учтите, что это сторонний сервис, который запустился совсем недавно и с самим проектом Vaultwarden не связан, так что доверять ему пароли стоит с осторожностью. Хотя пароли и уходят на сервер только в зашифрованном виде, метаданные (когда, с каких устройств и IP-адресов вы обращаетесь к хранилищу) тоже весьма приватная вещь.&lt;/p&gt;
&lt;h2&gt;Стек&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/dani-garcia/vaultwarden&quot;&gt;Vaultwarden&lt;/a&gt;&lt;/strong&gt; через &lt;a href=&quot;https://docs.docker.com/compose/&quot;&gt;Docker Compose&lt;/a&gt; с базой SQLite: пользователей будет немного, отдельный Postgres здесь не нужен&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://caddyserver.com/&quot;&gt;Caddy&lt;/a&gt;&lt;/strong&gt; в роли reverse proxy (с автоматическим HTTPS от &lt;a href=&quot;https://letsencrypt.org/&quot;&gt;Let&apos;s Encrypt&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SMTP&lt;/strong&gt; через почту на gluek.info: без него пригласить пользователя не получится, отдельной инвайт-ссылки у Vaultwarden нет&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Админка&lt;/strong&gt; &lt;code&gt;/admin&lt;/code&gt; включена и закрыта стойким паролем, захэшированным через Argon2&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Бэкапы&lt;/strong&gt; каждые сутки через rsync, а с них дополнительно копии на внешний диск (подход &lt;a href=&quot;https://www.backblaze.com/blog/the-3-2-1-backup-strategy/&quot;&gt;3-2-1&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Push-уведомления&lt;/strong&gt; для мобильных приложений через официальный relay Bitwarden (регион EU): бесплатные ключи выдают на &lt;a href=&quot;https://bitwarden.com/host/&quot;&gt;bitwarden.com/host&lt;/a&gt;, а через relay проходят только сигналы «пора синхронизироваться», без содержимого хранилища&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Клиенты&lt;/strong&gt; все те же, что были у 1Password: в основном приложение под Mac и расширение для браузера, приложение под Android и веб-версия на крайний случай&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Из приятных бонусов: в Bitwarden встроена функция &lt;strong&gt;&lt;a href=&quot;https://bitwarden.com/products/send/&quot;&gt;Send&lt;/a&gt;&lt;/strong&gt; для быстрой отправки текста или файла по ссылке. Для этого у меня, правда, уже есть отдельный сервис Cryptex на &lt;a href=&quot;https://drop.gluek.info/&quot;&gt;drop.gluek.info&lt;/a&gt;, про него расскажу отдельно.&lt;/p&gt;
&lt;h3&gt;Подводный камень: синхронизация в реальном времени&lt;/h3&gt;
&lt;p&gt;После запуска всё вроде бы работало, но изменения, сделанные на телефоне, не появлялись на Mac, пока не нажмёшь синхронизацию вручную. Оказалось, что в Caddyfile остался блок из старых инструкций, где &lt;code&gt;/notifications/hub&lt;/code&gt; проксируется на отдельный порт &lt;code&gt;3012&lt;/code&gt;. Начиная с Vaultwarden 1.29 WebSocket работает на основном порту, а &lt;code&gt;3012&lt;/code&gt; больше не существует, поэтому Caddy молча отвечал &lt;code&gt;502 Bad Gateway&lt;/code&gt;, и клиенты теряли обновления в реальном времени.&lt;/p&gt;
&lt;p&gt;Проверить это можно одной командой. Без токена рабочий сервер должен ответить &lt;code&gt;401&lt;/code&gt; от Vaultwarden (&lt;code&gt;Server: Rocket&lt;/code&gt;), а не &lt;code&gt;502&lt;/code&gt; от Caddy. &lt;code&gt;Sec-WebSocket-Key&lt;/code&gt; здесь не секрет, а обязательный для WebSocket-рукопожатия случайный nonce, взятый как пример прямо из &lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc6455#section-1.3&quot;&gt;RFC 6455&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -i --http1.1 -H &quot;Connection: Upgrade&quot; -H &quot;Upgrade: websocket&quot; \
  -H &quot;Sec-WebSocket-Version: 13&quot; -H &quot;Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==&quot; \
  https://vault.example.com/notifications/hub
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Лечится удалением отдельного блока: весь хост проксируется на Vaultwarden одним &lt;code&gt;reverse_proxy&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Для Android есть ещё одна тонкость: мобильные приложения получают изменения не по WebSocket, а через push, поэтому без настроенного relay они синхронизируются только при открытии. После включения push на телефоне нужно выйти из аккаунта и войти заново, чтобы устройство зарегистрировалось.&lt;/p&gt;
&lt;h2&gt;Перенос из 1Password&lt;/h2&gt;
&lt;p&gt;У Bitwarden есть встроенный импорт формата &lt;code&gt;.1pux&lt;/code&gt; (это экспорт 1Password 8), но с двумя нюансами:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Все сейфы сливаются в один.&lt;/strong&gt; У меня было около десяти сейфов, личных и общих, и импорт вывалил бы их в одну кучу. Мне нужно было разложить каждый сейф в свою организацию/коллекцию.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Вложения внутри записей теряются.&lt;/strong&gt; Отдельные документы (категория &lt;code&gt;Document&lt;/code&gt;) импортируются сами, а вот файлы, прикреплённые к логинам, заметкам и прочим записям, в импорт не попадают.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Для обеих проблем пришлось написать по небольшому скрипту на Python без внешних зависимостей, а позже понадобился и третий, для passkeys.&lt;/p&gt;
&lt;p&gt;Всего переехало больше 2000 записей. TOTP-коды перенеслись без проблем, SSH-ключи я храню отдельно, а вот passkeys в &lt;code&gt;.1pux&lt;/code&gt; не попадают вообще: их пришлось переносить отдельно, об этом ниже.&lt;/p&gt;
&lt;h3&gt;1. Нарезка экспорта по сейфам&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;.1pux&lt;/code&gt; — это обычный zip-архив с файлом &lt;code&gt;export.data&lt;/code&gt; (JSON со всеми записями) и папкой &lt;code&gt;files/&lt;/code&gt; с вложениями. Скрипт собирает из одного общего экспорта отдельный &lt;code&gt;.1pux&lt;/code&gt; на каждый сейф и кладёт туда только нужные ему вложения:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
&quot;&quot;&quot;
Разбивает экспорт 1Password (.1pux) на отдельные файлы по сейфам,
чтобы импортировать каждый в свою организацию/коллекцию Bitwarden.

Запуск:
  python3 split_1pux.py 1PasswordExport-XXXX.1pux
&quot;&quot;&quot;
import copy
import json
import sys
import zipfile

SRC = sys.argv[1]

with zipfile.ZipFile(SRC) as z:
    full_data = json.loads(z.read(&quot;export.data&quot;))
    attrs = z.read(&quot;export.attributes&quot;)
    all_names = z.namelist()

    vault_names = [v[&quot;attrs&quot;][&quot;name&quot;] for v in full_data[&quot;accounts&quot;][0][&quot;vaults&quot;]]

    for target_vault_name in vault_names:
        data = copy.deepcopy(full_data)
        for account in data[&quot;accounts&quot;]:
            account[&quot;vaults&quot;] = [v for v in account[&quot;vaults&quot;] if v[&quot;attrs&quot;][&quot;name&quot;] == target_vault_name]

        # собрать id вложений, которые нужны именно этому сейфу
        item_blob = json.dumps(data)
        kept_files = set()
        for name in all_names:
            if name.startswith(&quot;files/&quot;) and name != &quot;files/&quot;:
                file_id = name.split(&quot;/&quot;)[1].split(&quot;__&quot;)[0].split(&quot;.&quot;)[0]
                if file_id in item_blob:
                    kept_files.add(name)

        safe_name = target_vault_name.replace(&quot; &quot;, &quot;_&quot;).replace(&quot;&amp;amp;&quot;, &quot;and&quot;)
        out_name = f&quot;export_{safe_name}.1pux&quot;
        with zipfile.ZipFile(out_name, &quot;w&quot;, zipfile.ZIP_DEFLATED) as out:
            out.writestr(&quot;export.data&quot;, json.dumps(data))
            out.writestr(&quot;export.attributes&quot;, attrs)
            for name in kept_files:
                out.writestr(name, z.read(name))

        items = sum(len(v.get(&quot;items&quot;, [])) for a in data[&quot;accounts&quot;] for v in a[&quot;vaults&quot;])
        print(f&quot;{target_vault_name} -&amp;gt; {out_name} ({items} items, {len(kept_files)} files)&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Дальше каждый получившийся файл импортируется через веб-интерфейс: &lt;strong&gt;Tools → Import data → 1Password (1pux)&lt;/strong&gt;, с выбором нужной организации и коллекции.&lt;/p&gt;
&lt;h3&gt;2. Докачка вложений&lt;/h3&gt;
&lt;p&gt;Второй скрипт проходит по записям с вложенными файлами, находит соответствующую запись в Vaultwarden по названию и прикрепляет файлы через &lt;a href=&quot;https://bitwarden.com/help/cli/&quot;&gt;Bitwarden CLI&lt;/a&gt; (&lt;code&gt;bw&lt;/code&gt;). По умолчанию работает в режиме dry-run, а записи, которые не удалось однозначно сопоставить (дубли названий, пропавшие файлы), выводит списком для ручной доработки.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
&quot;&quot;&quot;
Прикрепляет вложения из .1pux к уже импортированным записям Vaultwarden
через Bitwarden CLI (bw). Берёт только записи, у которых файлы вложены
внутрь (не Document-категория 006 — те импортируются сами).

Перед запуском:
  bw config server https://vault.example.com
  bw login
  export BW_SESSION=$(bw unlock --raw)
  bw sync

Запуск:
  python3 attach_1pux.py export_Personal.1pux            # dry-run, ничего не меняет
  python3 attach_1pux.py export_Personal.1pux --apply    # реально загрузить
  python3 attach_1pux.py export_Work.1pux --org &amp;lt;organizationId&amp;gt; --apply
&quot;&quot;&quot;
import argparse
import json
import os
import shutil
import subprocess
import tempfile
import zipfile

p = argparse.ArgumentParser()
p.add_argument(&quot;pux&quot;, help=&quot;файл .1pux (один сейф)&quot;)
p.add_argument(&quot;--org&quot;, help=&quot;organizationId для общих сейфов; без него — личный сейф&quot;)
p.add_argument(&quot;--apply&quot;, action=&quot;store_true&quot;, help=&quot;реально загружать (по умолчанию dry-run)&quot;)
a = p.parse_args()


def bw(*args):
    r = subprocess.run([&quot;bw&quot;, *args], capture_output=True, text=True)
    if r.returncode:
        raise RuntimeError(r.stderr.strip() or r.stdout.strip())
    return r.stdout


def find_docs(obj, out):
    &quot;&quot;&quot;Рекурсивно собирает все объекты с documentId (описания файлов).&quot;&quot;&quot;
    if isinstance(obj, dict):
        if &quot;documentId&quot; in obj:
            out.append(obj)
        for v in obj.values():
            find_docs(v, out)
    elif isinstance(obj, list):
        for v in obj:
            find_docs(v, out)
    return out


z = zipfile.ZipFile(a.pux)

# documentId -&amp;gt; путь файла внутри архива (files/&amp;lt;id&amp;gt;__&amp;lt;name&amp;gt; или files/&amp;lt;id&amp;gt;.&amp;lt;ext&amp;gt;)
archive_files = {}
for n in z.namelist():
    if n.startswith(&quot;files/&quot;) and n != &quot;files/&quot;:
        doc_id = n.split(&quot;/&quot;, 1)[1].split(&quot;__&quot;)[0].split(&quot;.&quot;)[0]
        archive_files[doc_id] = n

data = json.loads(z.read(&quot;export.data&quot;))
items = []
for acc in data[&quot;accounts&quot;]:
    for v in acc[&quot;vaults&quot;]:
        for i in v.get(&quot;items&quot;, []):
            if i.get(&quot;categoryUuid&quot;) == &quot;006&quot;:
                continue
            docs = find_docs(i.get(&quot;details&quot;, {}), [])
            if docs:
                items.append((i, docs))

print(f&quot;Записей с вложениями внутри: {len(items)}&quot;)

# Все записи из Vaultwarden одним запросом, фильтр по личному сейфу / организации
vault_items = [
    x for x in json.loads(bw(&quot;list&quot;, &quot;items&quot;))
    if (x.get(&quot;organizationId&quot;) or None) == a.org
]
by_name = {}
for x in vault_items:
    by_name.setdefault(x[&quot;name&quot;], []).append(x)

tmp = tempfile.mkdtemp(prefix=&quot;1pux_att_&quot;)
manual = []
uploaded = skipped = 0

try:
    for item, docs in items:
        title = item[&quot;overview&quot;].get(&quot;title&quot;, &quot;&quot;)
        matches = by_name.get(title, [])
        if len(matches) != 1:
            manual.append(f&quot;{title}  (совпадений в Vaultwarden: {len(matches)})&quot;)
            continue
        cipher = matches[0]
        existing = {att.get(&quot;fileName&quot;) for att in cipher.get(&quot;attachments&quot;) or []}

        for d in docs:
            doc_id = d[&quot;documentId&quot;]
            src = archive_files.get(doc_id)
            if not src:
                manual.append(f&quot;{title}  (файл {doc_id} не найден в архиве)&quot;)
                continue
            fname = d.get(&quot;fileName&quot;) or os.path.basename(src)
            if fname in existing:
                skipped += 1
                continue

            print(f&quot;{&apos;UPLOAD&apos; if a.apply else &apos;DRY&apos;}  {title}  &amp;lt;-  {fname}&quot;)
            if a.apply:
                dest_dir = os.path.join(tmp, doc_id)
                os.makedirs(dest_dir, exist_ok=True)
                dest = os.path.join(dest_dir, fname)
                with open(dest, &quot;wb&quot;) as f:
                    f.write(z.read(src))
                bw(&quot;create&quot;, &quot;attachment&quot;, &quot;--file&quot;, dest, &quot;--itemid&quot;, cipher[&quot;id&quot;])
                os.remove(dest)
            uploaded += 1
finally:
    shutil.rmtree(tmp, ignore_errors=True)  # расшифрованные файлы не оставляем на диске

print(f&quot;\n{&apos;Загружено&apos; if a.apply else &apos;К загрузке&apos;}: {uploaded}, уже были: {skipped}&quot;)
if manual:
    print(&quot;\nПрикрепить вручную:&quot;)
    for m in manual:
        print(&quot;  -&quot;, m)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. Passkeys через CXP и чистка дублей&lt;/h3&gt;
&lt;p&gt;Passkeys удалось перенести через &lt;strong&gt;Credential Exchange Protocol (CXP)&lt;/strong&gt;, новый стандарт FIDO для переноса данных между менеджерами паролей напрямую, без промежуточного файла. Вместе с паролями и TOTP он переносит и сами ключи passkeys. На Android 14+ это делается прямо в приложении Bitwarden: &lt;strong&gt;Settings → Vault → Import items → Import from another app&lt;/strong&gt;, и дальше выбрать 1Password. Подробнее в &lt;a href=&quot;https://bitwarden.com/help/import-from-1password/&quot;&gt;документации Bitwarden&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Но CXP импортировал всё в личное хранилище, и поверх уже перенесённых записей получилась гора дублей. Встроенной дедупликации в Bitwarden нет, поэтому пришлось написать третий скрипт. Первая версия вызывала &lt;code&gt;bw&lt;/code&gt; на каждую запись, и на двух тысячах дублей это шло очень медленно, примерно одна запись в секунду: каждый запуск CLI заново расшифровывает всё хранилище. Поэтому скрипт работает через &lt;code&gt;bw serve&lt;/code&gt;, локальный REST API, где хранилище расшифровывается один раз, и это уже 4–5 записей в секунду. Он находит записи, созданные после CXP-импорта, и сравнивает их со старыми по названию, логину, паролю и TOTP. Точные дубли отправляются в корзину, а passkeys переносятся в старые записи, чтобы те остались в своих организациях рядом с вложениями. Новая запись удаляется, только если скрипт перечитал старую и убедился, что passkey в ней сохранился.&lt;/p&gt;
&lt;p&gt;В итоге удачно перенеслось больше 30 passkeys. Вручную, по одному сайту, это заняло бы пару часов.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
&quot;&quot;&quot;
Чистит дубли после импорта через CXP (Credential Exchange) в Vaultwarden/Bitwarden.

Новыми считаются записи личного хранилища, созданные после --since.
Для каждой новой записи ищется старая с теми же типом, названием,
логином, паролем и TOTP (в личном хранилище или в любой организации):
  - нет passkey  -&amp;gt; новая запись отправляется в корзину (не навсегда);
  - есть passkey -&amp;gt; с --move-passkeys passkey копируется в старую запись,
                    результат перепроверяется, и только тогда новая
                    запись уходит в корзину; иначе — в список «вручную».

Работает через локальный REST API Bitwarden CLI (bw serve): хранилище
расшифровывается один раз, а не на каждый вызов bw, поэтому в разы быстрее.

Перед запуском, в отдельном терминале:
  export BW_SESSION=$(bw unlock --raw)
  bw serve            # слушает только localhost:8087

Запуск:
  python3 dedup_cxp.py --since 2026-09-22T22:00                    # dry-run
  python3 dedup_cxp.py --since 2026-09-22T22:00 --move-passkeys    # dry-run с passkeys
  python3 dedup_cxp.py --since 2026-09-22T22:00 --move-passkeys --apply
&quot;&quot;&quot;
import argparse
import json
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime

p = argparse.ArgumentParser()
p.add_argument(&quot;--since&quot;, required=True, help=&quot;начало CXP-импорта, локальное время ISO (2026-09-22T22:00)&quot;)
p.add_argument(&quot;--move-passkeys&quot;, action=&quot;store_true&quot;, help=&quot;переносить passkeys в старые записи&quot;)
p.add_argument(&quot;--apply&quot;, action=&quot;store_true&quot;, help=&quot;реально изменять (по умолчанию dry-run)&quot;)
p.add_argument(&quot;--api&quot;, default=&quot;http://localhost:8087&quot;, help=&quot;адрес bw serve&quot;)
a = p.parse_args()


def api(method, path, body=None, retries=5):
    data = json.dumps(body).encode() if body is not None else None
    for attempt in range(retries):
        req = urllib.request.Request(a.api + path, data=data, method=method,
                                     headers={&quot;Content-Type&quot;: &quot;application/json&quot;})
        try:
            with urllib.request.urlopen(req, timeout=120) as r:
                res = json.loads(r.read() or b&quot;{}&quot;)
        except urllib.error.HTTPError as e:
            # bw serve сообщает об ошибках кодом 400 и JSON с message
            try:
                res = json.loads(e.read() or b&quot;{}&quot;)
            except ValueError:
                res = {&quot;message&quot;: f&quot;HTTP {e.code}&quot;}
        except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
            res = {&quot;message&quot;: f&quot;FetchError: {e}&quot;}
        if res.get(&quot;success&quot;):
            return res.get(&quot;data&quot;)
        msg = str(res.get(&quot;message&quot;) or res)
        # сетевые сбои (ECONNRESET, таймауты) повторяем с паузой, остальное — сразу ошибка
        if &quot;FetchError&quot; not in msg or attempt == retries - 1:
            raise RuntimeError(msg)
        time.sleep(2 ** attempt)


def parse_ts(s):
    t = datetime.fromisoformat(s.replace(&quot;Z&quot;, &quot;+00:00&quot;))
    return t if t.tzinfo else t.astimezone()  # без зоны — считаем локальным временем


def key(x):
    login = x.get(&quot;login&quot;) or {}
    if x.get(&quot;type&quot;) == 1:
        return (1, x.get(&quot;name&quot;), login.get(&quot;username&quot;), login.get(&quot;password&quot;), login.get(&quot;totp&quot;))
    return (x.get(&quot;type&quot;), x.get(&quot;name&quot;), x.get(&quot;notes&quot;))


def passkeys(x):
    return (x.get(&quot;login&quot;) or {}).get(&quot;fido2Credentials&quot;) or []


def trash(x):
    &quot;&quot;&quot;В корзину, не навсегда. Ошибку не пробрасываем — запись уйдёт в «вручную».&quot;&quot;&quot;
    if not a.apply:
        return True
    try:
        api(&quot;DELETE&quot;, f&quot;/object/item/{x[&apos;id&apos;]}&quot;)
        return True
    except RuntimeError as e:
        manual.append(f&quot;{x[&apos;name&apos;]}  (ошибка: {e})&quot;)
        return False


try:
    status = api(&quot;GET&quot;, &quot;/status&quot;)[&quot;template&quot;][&quot;status&quot;]
except RuntimeError as e:
    sys.exit(f&quot;bw serve недоступен на {a.api}: {e}&quot;)
if status != &quot;unlocked&quot;:
    sys.exit(f&quot;Хранилище в статусе «{status}» — запусти bw serve с BW_SESSION от bw unlock&quot;)

api(&quot;POST&quot;, &quot;/sync&quot;)  # свежие данные: после прерванного запуска часть записей уже в корзине
items = api(&quot;GET&quot;, &quot;/list/object/items&quot;)[&quot;data&quot;]
if items and &quot;creationDate&quot; not in items[0]:
    sys.exit(&quot;bw не отдаёт creationDate — обнови Bitwarden CLI&quot;)

since = parse_ts(a.since)
new, old = [], {}
for x in items:
    if not x.get(&quot;organizationId&quot;) and parse_ts(x[&quot;creationDate&quot;]) &amp;gt;= since:
        new.append(x)
    else:
        old.setdefault(key(x), []).append(x)

print(f&quot;Новых записей после {since:%Y-%m-%d %H:%M}: {len(new)}, старых: {sum(map(len, old.values()))}\n&quot;)

trashed = moved = 0
unique, manual = [], []

for x in new:
    matches = old.get(key(x), [])
    pk = passkeys(x)
    if not matches:
        unique.append(x[&quot;name&quot;])
        continue

    if pk:
        ids = {c.get(&quot;credentialId&quot;) for c in pk}
        if any(ids &amp;lt;= {c.get(&quot;credentialId&quot;) for c in passkeys(m)} for m in matches):
            # passkey уже перенесён прошлым запуском, осталось убрать новую запись
            print(f&quot;{&apos;TRASH&apos; if a.apply else &apos;DRY&apos;}  {x[&apos;name&apos;]}  (passkey уже в старой записи)&quot;)
            trashed += trash(x)
            continue
        if not a.move_passkeys:
            manual.append(f&quot;{x[&apos;name&apos;]}  (есть passkey, запусти с --move-passkeys)&quot;)
            continue
        targets = [m for m in matches if not passkeys(m)]
        if len(targets) != 1:
            manual.append(f&quot;{x[&apos;name&apos;]}  (passkey; подходящих старых записей: {len(targets)})&quot;)
            continue
        target = targets[0]
        print(f&quot;{&apos;MOVE&apos; if a.apply else &apos;DRY&apos;}   passkey  {x[&apos;name&apos;]}&quot;)
        if a.apply:
            try:
                full = api(&quot;GET&quot;, f&quot;/object/item/{target[&apos;id&apos;]}&quot;)
                full[&quot;login&quot;][&quot;fido2Credentials&quot;] = pk
                api(&quot;PUT&quot;, f&quot;/object/item/{target[&apos;id&apos;]}&quot;, full)
                check = api(&quot;GET&quot;, f&quot;/object/item/{target[&apos;id&apos;]}&quot;)
            except RuntimeError as e:
                manual.append(f&quot;{x[&apos;name&apos;]}  (ошибка: {e})&quot;)
                continue
            if not ids &amp;lt;= {c.get(&quot;credentialId&quot;) for c in passkeys(check)}:
                manual.append(f&quot;{x[&apos;name&apos;]}  (CLI не сохранил passkey в старую запись, новая оставлена)&quot;)
                continue
            if not trash(x):
                continue
        moved += 1
        continue

    print(f&quot;{&apos;TRASH&apos; if a.apply else &apos;DRY&apos;}  {x[&apos;name&apos;]}&quot;)
    trashed += trash(x)

print(f&quot;\n{&apos;В корзине&apos; if a.apply else &apos;К удалению&apos;}: {trashed}, passkeys перенесено: {moved}&quot;)
if unique:
    print(f&quot;\nБез пары среди старых (оставлены, стоит глянуть глазами): {len(unique)}&quot;)
    for n in unique:
        print(&quot;  -&quot;, n)
if manual:
    print(&quot;\nРазобрать вручную:&quot;)
    for m in manual:
        print(&quot;  -&quot;, m)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;P.S. ⚠️ Файлы &lt;code&gt;.1pux&lt;/code&gt; &lt;strong&gt;не зашифрованы&lt;/strong&gt; и там все пароли лежат открытым текстом. После переноса их стоит удалить (и из корзины тоже), а не оставлять в «Загрузках».&lt;/p&gt;
</content:encoded></item></channel></rss>