<?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>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><item><title>Claude Code</title><link>https://gluek.info/blog/claude-code/</link><guid isPermaLink="true">https://gluek.info/blog/claude-code/</guid><description>Закончился год бесплатного Google AI Pro, который шёл бонусом при покупке телефона и я решил попробовать для кодинга Claude Code с Sonnet/Opus вмес...</description><pubDate>Mon, 21 Sep 2026 00:30:54 GMT</pubDate><content:encoded>&lt;p&gt;Закончился год бесплатного Google AI Pro, который шёл бонусом при покупке телефона и я решил попробовать для кодинга Claude Code с Sonnet/Opus вместо Antigravity с Gemini Flash/Pro (которая давно застряла у них на версии 3.1). И похоже что Google уже окончально проиграл эту гонку, потому что Sonnet делает всё и быстрее и качественнее, правда пока тренируюсь только на ботах для Delta Chat, но разница в подходах уже видна, у гугла это усердный джун, которому надо всё разжевать и тогда он сделает, а у клода вышел крепкий мидл, который и архитектуру может поправить, и быстрые фиксы сделать без ошибок, и в багах пойти потом разобраться сам, без подсказок.&lt;/p&gt;
&lt;p&gt;Да, попробовал до этого все китайские модели через OpenCode, они тоже что-то делают, но прямо ощущается, что китайские...&lt;/p&gt;
</content:encoded></item><item><title>So...</title><link>https://gluek.info/blog/so/</link><guid isPermaLink="true">https://gluek.info/blog/so/</guid><description>Переехал на собственный инстанс федиверса so.gluek.info (поставил туда [GTS](https://gotosocial.org)) с mastodon.social, по идее все подписки должн...</description><pubDate>Mon, 14 Sep 2026 00:31:37 GMT</pubDate><content:encoded>&lt;p&gt;Переехал на собственный инстанс федиверса so.gluek.info (поставил туда &lt;a href=&quot;https://gotosocial.org&quot;&gt;GTS&lt;/a&gt;) с mastodon.social, по идее все подписки должны сохраниться, но если нет, вот ссылка: &lt;a href=&quot;https://so.gluek.info/@gluek&quot;&gt;https://so.gluek.info/@gluek&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@gluek@so.gluek.info
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;P.S. Буду писать короткие заметки туда, чтобы не забивать блог.&lt;/p&gt;
</content:encoded></item><item><title>Настройка QoS для Chatmail сервера</title><link>https://gluek.info/blog/nastroyka-qos-dlya-chatmail-servera/</link><guid isPermaLink="true">https://gluek.info/blog/nastroyka-qos-dlya-chatmail-servera/</guid><description>Оптимизация почтового релея Chatmail под типовые VPS «2 ядра, 2 гига»: двухуровневая маршрутизация в Postfix для мгновенной доставки текста и защиты от OOM.</description><pubDate>Fri, 11 Sep 2026 00:20:00 GMT</pubDate><content:encoded>&lt;p&gt;В экосистеме &lt;a href=&quot;https://delta.chat&quot;&gt;Delta Chat&lt;/a&gt; почта работает транспортом для мессенджера: серверы-релеи (&lt;a href=&quot;https://chatmail.at&quot;&gt;Chatmail&lt;/a&gt;) крутятся на связке Postfix, Dovecot и Rust-демона Filtermail. Для пользователей это привычный чат, но на скромном VPS с конфигурацией «2 ядра, 2 гига» легко поймать перегруз, даже если кто-то скинет в активную группу одно видео на 50 МБ.&lt;/p&gt;
&lt;p&gt;А если попытаться решить проблему стандартными лимитами очередей, обычный текст в чате начинает доходить с двухминутными задержками.&lt;/p&gt;
&lt;p&gt;Ниже — разбор проблемы и настройка двухуровневой маршрутизации в Postfix, которая сохраняет моментальную доставку текста (0.1–0.5 с) и защищает сервер от падений по памяти.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;1. Анатомия проблемы: откуда берутся 2.6 ГБ RAM на одно письмо&lt;/h2&gt;
&lt;p&gt;Мой сервер-релей &lt;code&gt;chat.gluek.info&lt;/code&gt; крутится на типовом VPS: 2 vCPU, 2 ГБ RAM, Debian 12.&lt;/p&gt;
&lt;p&gt;Когда авторизованный клиент отправляет сообщение, оно проходит цепочку: &lt;code&gt;submission (587)&lt;/code&gt; ➔ &lt;code&gt;filtermail outgoing (10080)&lt;/code&gt; (проверка E2EE и лимитов) ➔ реинжект в Postfix на порт &lt;code&gt;10025&lt;/code&gt; ➔ очередь &lt;code&gt;qmgr&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Для доставки внешним адресатам Postfix передает сообщение через LMTP на локальный сервис &lt;code&gt;filtermail-transport&lt;/code&gt; (&lt;code&gt;127.0.0.1:10083&lt;/code&gt;), написанный на Rust. Этот сервис отправляет почту по HTTPS (&lt;code&gt;/mxdeliv&lt;/code&gt;), а если удаленный сервер этого не умеет — по классическому SMTP с STARTTLS.&lt;/p&gt;
&lt;p&gt;Смотрим в исходники &lt;code&gt;filtermail/src/transport.rs&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// filtermail/src/transport.rs
for (rcpt_domain, rcpts) in &amp;amp;domain_rcpts_map {
    let domain_envelope = {
        let mut envelope = transaction.envelope.clone(); // &amp;lt;--- Клонирование тела в RAM
        envelope.rcpt_to = rcpts.clone();
        envelope
    };
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;А теперь важный нюанс: в активной группе может быть чуть больше 100 участников, но у многих к аккаунту привязано по несколько релеев (резервных почтовых серверов). В ядре Delta Chat сейчас нет разделения приоритетов — чтобы отправить сообщение сначала на основной адрес, а на остальные адреса чуть погодя и без спешки. Сервер обязан отправить копию каждому участнику и на каждый его адрес.&lt;/p&gt;
&lt;p&gt;В итоге одно сообщение в группе на 100+ человек генерирует рассылку на &lt;strong&gt;~350 адресов&lt;/strong&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Получатели размазаны примерно по &lt;strong&gt;50 уникальным внешним доменам&lt;/strong&gt; (&lt;code&gt;chatmail.uk&lt;/code&gt;, &lt;code&gt;yandex.ru&lt;/code&gt;, &lt;code&gt;mail.ru&lt;/code&gt; и т.д.).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;filtermail-transport&lt;/code&gt; группирует получателей по доменам и для каждого домена делает &lt;code&gt;transaction.envelope.clone()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Поле &lt;code&gt;envelope&lt;/code&gt; держит в оперативной памяти &lt;strong&gt;полное тело письма&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Считаем:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;50 доменов × 53.6 МБ (видео) = 2.68 ГБ RAM&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Rust пытается аллоцировать 2.68 ГБ памяти на машине с 2 ГБ RAM. Сервер моментально выедает свободную память, уходит в дисковый своп-трешинг (swap thrashing), LMTP-соединения отваливаются по таймауту (&lt;code&gt;Timeout while waiting for lock&lt;/code&gt;), CPU упирается в 100%, и сервер перестает отвечать даже по SSH.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. Ловушка простого решения: задержка в 116 секунд на текстовое сообщение&lt;/h2&gt;
&lt;p&gt;Первая мысль для защиты от OOM: ограничить размер пачки получателей в Postfix, чтобы он отдавал в LMTP не больше 25 адресов за раз.&lt;/p&gt;
&lt;p&gt;Прописываем в &lt;code&gt;/etc/postfix/main.cf&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;lmtp-filtermail_destination_recipient_limit = 25
lmtp-filtermail_destination_concurrency_limit = 10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Сервер перестает падать: 25 адресов — это не больше 10–15 доменов, то есть до 500–750 МБ в пике.&lt;/p&gt;
&lt;p&gt;Но тут же появляется другая проблема: &lt;strong&gt;сообщения в чате начинают доходить с огромной задержкой.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Смотрим логи:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;postfix/lmtp-filtermail/lmtp[2908493]: B0A325740D: to=&amp;lt;user@remote.xyz&amp;gt;, 
relay=127.0.0.1[10083], delay=116, delays=0.42/115/0/0.72, dsn=2.0.0, status=sent
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Метрика &lt;code&gt;delays=a/b/c/d&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;a = 0.42s&lt;/code&gt;: время до попадания в активную очередь;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;b = 115s&lt;/code&gt;&lt;/strong&gt;: &lt;strong&gt;время ожидания в очереди Postfix (queue delay);&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;c = 0s&lt;/code&gt;: установка соединения;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;d = 0.72s&lt;/code&gt;: передача данных.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Сообщение весило всего &lt;strong&gt;35 КБ&lt;/strong&gt; (обычный текст). Но из-за &lt;code&gt;recipient_limit = 25&lt;/code&gt; Postfix распилил список из 357 адресов на &lt;strong&gt;15 отдельных пачек&lt;/strong&gt;. Все они встали в очередь на 10 LMTP-воркеров. Пока удаленные почтовые серверы отвечали (DNS MX, TLS handshakes, greylisting), хвост очереди провисел &lt;strong&gt;115 секунд&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Заставлять текстовые сообщения в мессенджере ждать две минуты только потому, что кто-то когда-то скинет видео — неприемлемо.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. Архитектура: двухскоростной QoS в Postfix&lt;/h2&gt;
&lt;p&gt;Для текста 35 КБ клонирование 50 раз требует всего &lt;strong&gt;1.75 МБ RAM&lt;/strong&gt; ($50 \times 35 \text{ КБ}$). Резать его на пачки вообще не нужно — 350 адресов должны улетать в &lt;code&gt;filtermail&lt;/code&gt; одной транзакцией за доли секунды.&lt;/p&gt;
&lt;p&gt;А вот для писем ≥ 10 МБ нужна строгая нарезка и жесткий лимит параллелизма.&lt;/p&gt;
&lt;p&gt;Схема двухскоростной маршрутизации выглядит так:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;      Клиент (Delta Chat / SMTP)
                 │
                 ▼
     [submission :587 / smtps :465]
                 │
                 ▼
     [filtermail outgoing :10080]
                 │
                 ▼
       [reinject smtpd :10025]
                 │
       ┌─────────┴─────────┐
       ▼                   ▼
  Локальные юзеры     Внешние получатели
 (@chat.gluek.info)        │
       │                   ▼
       │          [postfix-size-policy]
       │          (проверка веса в END-OF-MESSAGE)
       │                   │
       │         ┌─────────┴─────────┐
       │         ▼                   ▼
       │     &amp;lt; 10 МБ              ≥ 10 МБ
       │     (action=DUNNO)      (action=FILTER ...)
       │         │                   │
       │         ▼                   ▼
       │    🚀 Fast Lane        🐘 Heavy Lane
       │  (lmtp-filtermail)    (lmtp-filtermail-heavy)
       │  • batch: 500         • batch: 25
       │  • workers: 20        • workers: 5
       │  • задержка: 0.2с     • RAM: до 600 МБ
       │         │                   │
       │         └─────────┬─────────┘
       ▼                   ▼
   [dovecot-lmtp]    [filtermail-transport :10083]
   (прямо на диск)         │
                           ▼
                    В интернет с E2EE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Принципы схемы:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Быстрая полоса (&lt;code&gt;lmtp-filtermail&lt;/code&gt;)&lt;/strong&gt;: &lt;code&gt;recipient_limit = 500&lt;/code&gt;, 20 воркеров. Легкие сообщения уходят сразу одной пачкой.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Тяжелая полоса (&lt;code&gt;lmtp-filtermail-heavy&lt;/code&gt;)&lt;/strong&gt;: &lt;code&gt;recipient_limit = 25&lt;/code&gt;, 5 воркеров. Большие файлы дозируются небольшими порциями.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Локальный байпас&lt;/strong&gt;: если видео отправлено между пользователями своего сервера (&lt;code&gt;@chat.gluek.info&lt;/code&gt;), оно не идет во внешний транспорт, а сразу кладется в Dovecot maildir.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Отказоустойчивость&lt;/strong&gt;: если скрипт классификатора зависнет или упадет, Postfix сработает по &lt;code&gt;default_action = DUNNO&lt;/code&gt; — ни одно сообщение не потеряется.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;4. Пошаговая настройка&lt;/h2&gt;
&lt;h3&gt;Шаг 0. Включаем ZRAM&lt;/h3&gt;
&lt;p&gt;В качестве страховки от всплесков памяти включаем сжатый своп в RAM с алгоритмом &lt;code&gt;zstd&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apt install -y zram-tools
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;В &lt;code&gt;/etc/default/zramswap&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ALGO=zstd
PERCENT=60
PRIORITY=100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Перезапускаем:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;systemctl restart zramswap
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Это дает около 1.2 ГБ виртуальной памяти со сжатием ~5–6x, работающей на скорости ОЗУ без нагрузки на диск.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;Шаг 1. Скрипт классификации: &lt;code&gt;/usr/local/bin/postfix-size-policy.py&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Postfix поддерживает &lt;a href=&quot;https://www.postfix.org/SMTPD_POLICY_README.html&quot;&gt;SMTPD Policy Delegation&lt;/a&gt;. На этапе &lt;code&gt;END-OF-MESSAGE&lt;/code&gt; демон уже знает точный размер письма (&lt;code&gt;size=...&lt;/code&gt;) и всех получателей.&lt;/p&gt;
&lt;p&gt;Создаем &lt;code&gt;/usr/local/bin/postfix-size-policy.py&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
&quot;&quot;&quot;
Postfix policy service для двухскоростной QoS-маршрутизации и логирования.
- Сообщения &amp;gt;= 10 МБ с внешними получателями отправляются в lmtp-filtermail-heavy.
- Сообщения &amp;lt; 10 МБ или чисто локальные получают DUNNO (дефолтный быстрый маршрут).
- Ведет учет статистики в SQLite WAL.
&quot;&quot;&quot;

import configparser
import os
import sqlite3
import sys
import syslog
import time

THRESHOLD_BYTES = 10 * 1024 * 1024  # 10 МБ
DB_PATH = &quot;/var/lib/postfix-qos/stats.db&quot;

# Автоопределение локального домена из конфига chatmail
LOCAL_DOMAIN = None
try:
    cp = configparser.ConfigParser()
    if os.path.exists(&quot;/usr/local/lib/chatmaild/chatmail.ini&quot;):
        cp.read(&quot;/usr/local/lib/chatmaild/chatmail.ini&quot;)
        LOCAL_DOMAIN = cp.get(&quot;params&quot;, &quot;mail_domain&quot;, fallback=None)
except Exception:
    pass

if not LOCAL_DOMAIN:
    LOCAL_DOMAIN = sys.argv[1] if len(sys.argv) &amp;gt; 1 else &quot;chat.gluek.info&quot;

transactions = {}

def log(msg):
    syslog.syslog(syslog.LOG_INFO, f&quot;postfix-size-policy: {msg}&quot;)

def init_db():
    try:
        with sqlite3.connect(DB_PATH, timeout=2) as conn:
            conn.execute(&quot;PRAGMA journal_mode=WAL&quot;)
            conn.execute(&quot;&quot;&quot;CREATE TABLE IF NOT EXISTS qos_events (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                ts REAL,
                queue_id TEXT,
                size INTEGER,
                rcpt_count INTEGER,
                has_external INTEGER,
                action TEXT
            )&quot;&quot;&quot;)
    except Exception as e:
        log(f&quot;Failed to init db: {e}&quot;)

def record_event(queue_id, size, rcpt_count, has_external, action):
    try:
        with sqlite3.connect(DB_PATH, timeout=2) as conn:
            conn.execute(
                &quot;INSERT INTO qos_events (ts, queue_id, size, rcpt_count, has_external, action) &quot;
                &quot;VALUES (?, ?, ?, ?, ?, ?)&quot;,
                (time.time(), queue_id, size, rcpt_count, 1 if has_external else 0, action)
            )
    except Exception as e:
        log(f&quot;Failed to record event: {e}&quot;)

def cleanup_expired():
    now = time.time()
    expired = [inst for inst, data in transactions.items() if now - data.get(&quot;ts&quot;, now) &amp;gt; 600]
    for inst in expired:
        transactions.pop(inst, None)

def main():
    syslog.openlog(facility=syslog.LOG_MAIL)
    init_db()

    while True:
        attrs = {}
        while True:
            line = sys.stdin.readline()
            if not line:
                return
            line = line.strip()
            if not line:
                break
            if &quot;=&quot; in line:
                k, v = line.split(&quot;=&quot;, 1)
                attrs[k] = v

        if not attrs:
            continue

        protocol_state = attrs.get(&quot;protocol_state&quot;, &quot;&quot;)
        instance = attrs.get(&quot;instance&quot;, &quot;&quot;)

        if len(transactions) &amp;gt; 500:
            cleanup_expired()

        if protocol_state == &quot;RCPT&quot;:
            recipient = attrs.get(&quot;recipient&quot;, &quot;&quot;).lower().strip()
            if instance and recipient:
                if instance not in transactions:
                    transactions[instance] = {&quot;rcpts&quot;: set(), &quot;ts&quot;: time.time()}
                transactions[instance][&quot;rcpts&quot;].add(recipient)
            sys.stdout.write(&quot;action=DUNNO\n\n&quot;)
            sys.stdout.flush()

        elif protocol_state in (&quot;END-OF-DATA&quot;, &quot;END-OF-MESSAGE&quot;):
            try:
                size = int(attrs.get(&quot;size&quot;, 0))
            except ValueError:
                size = 0

            queue_id = attrs.get(&quot;queue_id&quot;, &quot;&quot;)
            txn = transactions.pop(instance, None)
            rcpts = txn[&quot;rcpts&quot;] if txn else set()
            end_rcpt = attrs.get(&quot;recipient&quot;, &quot;&quot;).lower().strip()
            if end_rcpt:
                rcpts.add(end_rcpt)

            has_external = True
            if rcpts and LOCAL_DOMAIN:
                has_external = any(not r.endswith(&quot;@&quot; + LOCAL_DOMAIN.lower()) for r in rcpts)

            if size &amp;gt;= THRESHOLD_BYTES and has_external:
                log(f&quot;Heavy message detected (size={size} bytes, rcpts={len(rcpts)}): routing to lmtp-filtermail-heavy&quot;)
                record_event(queue_id, size, len(rcpts), has_external, &quot;HEAVY&quot;)
                sys.stdout.write(&quot;action=FILTER lmtp-filtermail-heavy:inet:[127.0.0.1]:10083\n\n&quot;)
            else:
                action_type = &quot;LOCAL&quot; if not has_external else &quot;FAST&quot;
                record_event(queue_id, size, len(rcpts), has_external, action_type)
                sys.stdout.write(&quot;action=DUNNO\n\n&quot;)
            sys.stdout.flush()

        else:
            sys.stdout.write(&quot;action=DUNNO\n\n&quot;)
            sys.stdout.flush()

if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Выставляем права и создаем директорию для базы:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;chmod 755 /usr/local/bin/postfix-size-policy.py
mkdir -p /var/lib/postfix-qos
chown -R nobody:nogroup /var/lib/postfix-qos
chmod 775 /var/lib/postfix-qos
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;Шаг 2. Настройка &lt;code&gt;master.cf&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;В &lt;code&gt;/etc/postfix/master.cf&lt;/code&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Подключаем политику к реинжекту исходящей почты (&lt;code&gt;127.0.0.1:10025&lt;/code&gt;):&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;# Local SMTP server for reinjecting outgoing filtered mail.
127.0.0.1:10025 inet  n       -       n       -       100      smtpd
  -o syslog_name=postfix/reinject
  -o milter_macro_daemon_name=ORIGINATING
  -o cleanup_service_name=authclean
  -o smtpd_relay_restrictions=permit_mynetworks,reject
  -o smtpd_milters=unix:opendkim/opendkim.sock
  -o { smtpd_recipient_restrictions = check_policy_service unix:private/policy-size }
  -o { smtpd_end_of_data_restrictions = check_policy_service unix:private/policy-size }
  -o smtpd_policy_service_default_action=DUNNO
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Объявляем сервис policy-демона:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;# QoS size policy daemon
policy-size  unix  -       n       n       -       0       spawn
  user=nobody argv=/usr/local/bin/postfix-size-policy.py
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Задаем быстрый и тяжелый LMTP-транспорты:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;# Fast Lane (сообщения &amp;lt; 10MB)
lmtp-filtermail unix  -       -       y       -       20       lmtp
  -o syslog_name=postfix/lmtp-filtermail
  -o lmtp_header_checks=
  -o lmtp_tls_security_level=none

# Heavy Lane (сообщения &amp;gt;= 10MB)
lmtp-filtermail-heavy unix  -       -       y       -       5       lmtp
  -o syslog_name=postfix/lmtp-filtermail-heavy
  -o lmtp_header_checks=
  -o lmtp_tls_security_level=none
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;Шаг 3. Лимиты очередей в &lt;code&gt;main.cf&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;В &lt;code&gt;/etc/postfix/main.cf&lt;/code&gt; задаем раздельные лимиты:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Основной транспорт по умолчанию — Быстрая полоса
default_transport = lmtp-filtermail:inet:[127.0.0.1]:10083

# Fast Lane: сообщения &amp;lt; 10MB улетают пачками до 500 адресов
lmtp-filtermail_destination_recipient_limit = 500
lmtp-filtermail_destination_concurrency_limit = 20
lmtp-filtermail_initial_destination_concurrency = 20
lmtp-filtermail_destination_concurrency_negative_feedback = 0
lmtp-filtermail_destination_concurrency_failed_cohort_limit = 0

# Heavy Lane: файлы &amp;gt;= 10MB режутся пачками по 25 адресов
lmtp-filtermail-heavy_destination_recipient_limit = 25
lmtp-filtermail-heavy_destination_concurrency_limit = 5
lmtp-filtermail-heavy_initial_destination_concurrency = 5
lmtp-filtermail-heavy_destination_concurrency_negative_feedback = 0
lmtp-filtermail-heavy_destination_concurrency_failed_cohort_limit = 0

# Быстрый повтор при сетевых ошибках вместо ожидания 5 минут
minimal_backoff_time = 30s
queue_run_delay = 30s
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Важно:&lt;/strong&gt; параметры &lt;code&gt;failed_cohort_limit = 0&lt;/code&gt; и &lt;code&gt;negative_feedback = 0&lt;/code&gt; критичны. Без них при временном сбое внешнего MX-сервера Postfix может решить, что «упал» локальный релей &lt;code&gt;127.0.0.1:10083&lt;/code&gt;, и заморозить исходящую очередь на сервере.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Применяем конфигурацию:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;postfix check &amp;amp;&amp;amp; postfix reload
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;5. Мониторинг и отчет в ntfy&lt;/h2&gt;
&lt;p&gt;Чтобы видеть работу очередей, я сделал скрипт &lt;code&gt;/usr/local/bin/chatmail-status-report.py&lt;/code&gt;, присылающий отчет раз в сутки в моего &lt;a href=&quot;https://ntfy.gluek.info&quot;&gt;ntfy бота&lt;/a&gt; прямо в Delta Chat (&lt;a href=&quot;https://ntfy.gluek.info/chatmail-status&quot;&gt;chatmail-status&lt;/a&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
&quot;&quot;&quot;
Ежедневный отчет QoS и здоровья сервера в ntfy.gluek.info.
&quot;&quot;&quot;

import argparse
import os
import re
import sqlite3
import subprocess
import sys
import time
import urllib.request

NTFY_URL = &quot;https://ntfy.gluek.info/chatmail-status&quot;
DB_PATH = &quot;/var/lib/postfix-qos/stats.db&quot;

def get_system_metrics():
    metrics = {}
    with open(&quot;/proc/loadavg&quot;, &quot;r&quot;) as f:
        p = f.read().strip().split()
        metrics[&quot;load&quot;] = f&quot;{p[0]}, {p[1]}, {p[2]}&quot;

    mem = {}
    with open(&quot;/proc/meminfo&quot;, &quot;r&quot;) as f:
        for line in f:
            parts = line.split(&quot;:&quot;)
            if len(parts) == 2:
                mem[parts[0].strip()] = int(parts[1].split()[0])
    total = mem.get(&quot;MemTotal&quot;, 0) / 1024
    avail = mem.get(&quot;MemAvailable&quot;, 0) / 1024
    metrics[&quot;ram&quot;] = f&quot;{total - avail:.0f} MB / {total:.0f} MB ({avail:.0f} MB free)&quot;

    p = subprocess.run([&quot;zramctl&quot;, &quot;--bytes&quot;, &quot;--noheadings&quot;, &quot;--output&quot;, &quot;DATA,COMPR,TOTAL&quot;],
                       capture_output=True, text=True)
    if p.returncode == 0 and p.stdout.strip():
        data, compr, _ = [int(x) for x in p.stdout.strip().split()[:3]]
        ratio = (data / compr) if compr &amp;gt; 0 else 1.0
        compr_kb = compr / 1024
        metrics[&quot;zram&quot;] = f&quot;{compr_kb:.0f} KB (data: {data/1024:.0f} KB, ratio: {ratio:.1f}x)&quot;
    else:
        metrics[&quot;zram&quot;] = &quot;Inactive&quot;

    p = subprocess.run([&quot;mailq&quot;], capture_output=True, text=True)
    m = re.search(r&quot;in\s+(\d+)\s+Requests?&quot;, p.stdout)
    metrics[&quot;queue&quot;] = f&quot;{m.group(1)} messages&quot; if m else &quot;0 messages (empty)&quot;

    st = os.statvfs(&quot;/&quot;)
    metrics[&quot;disk&quot;] = f&quot;{(st.f_bavail * st.f_frsize) / (1024**3):.1f} GB free&quot;
    return metrics

def get_delivery_stats():
    cmd = [&quot;journalctl&quot;, &quot;-u&quot;, &quot;postfix@-.service&quot;, &quot;--since=24 hours ago&quot;, &quot;--no-pager&quot;]
    p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
    stats = {
        &quot;fast&quot;: {&quot;sent&quot;: 0, &quot;deferred&quot;: 0, &quot;bounced&quot;: 0, &quot;delays&quot;: []},
        &quot;heavy&quot;: {&quot;sent&quot;: 0, &quot;deferred&quot;: 0, &quot;bounced&quot;: 0},
        &quot;local&quot;: {&quot;sent&quot;: 0},
        &quot;smtp&quot;: {&quot;sent&quot;: 0}
    }
    for line in p.stdout:
        if &quot;status=&quot; not in line:
            continue
        tr = None
        if &quot;postfix/lmtp-filtermail-heavy/lmtp&quot; in line: tr = &quot;heavy&quot;
        elif &quot;postfix/lmtp-filtermail/lmtp&quot; in line: tr = &quot;fast&quot;
        elif &quot;postfix/lmtp&quot; in line and &quot;dovecot&quot; in line: tr = &quot;local&quot;
        elif &quot;postfix/smtp&quot; in line: tr = &quot;smtp&quot;
        if not tr: continue

        status = &quot;sent&quot; if &quot;status=sent&quot; in line else (&quot;deferred&quot; if &quot;status=deferred&quot; in line else &quot;bounced&quot;)
        if status in stats[tr]: stats[tr][status] += 1

        if tr == &quot;fast&quot; and status == &quot;sent&quot;:
            m = re.search(r&quot;delay=([0-9.]+)&quot;, line)
            if m: stats[tr][&quot;delays&quot;].append(float(m.group(1)))
    p.wait()
    return stats

def build_report():
    sys_m = get_system_metrics()
    deliv = get_delivery_stats()

    d = sorted(deliv[&quot;fast&quot;][&quot;delays&quot;])
    avg_d = sum(d) / len(d) if d else 0
    p95_d = d[int(len(d) * 0.95)] if d else 0
    max_d = d[-1] if d else 0

    lines = [
        &quot;📊 **Chatmail: Daily Status &amp;amp; QoS Report**&quot;,
        &quot;Server: `chat.gluek.info` | Period: Last 24 Hours\n&quot;,
        &quot;🚀 **Fast Lane (lmtp-filtermail, &amp;lt; 10 MB)**&quot;,
        f&quot;• Delivered: **{deliv[&apos;fast&apos;][&apos;sent&apos;]:,}** messages&quot;,
        f&quot;• Latency: avg **{avg_d:.1f}s** | p95 **{p95_d:.1f}s** | max **{max_d:.1f}s**&quot;,
        f&quot;• Deferred: {deliv[&apos;fast&apos;][&apos;deferred&apos;]} | Bounced: {deliv[&apos;fast&apos;][&apos;bounced&apos;]}\n&quot;,
        &quot;🐘 **Heavy Lane (lmtp-filtermail-heavy, ≥ 10 MB)**&quot;,
        f&quot;• Heavy messages routed: **{deliv[&apos;heavy&apos;][&apos;sent&apos;] + deliv[&apos;heavy&apos;][&apos;deferred&apos;]}**&quot;,
        &quot;• Status: 25 rcpt batching, 0 OOM crashes\n&quot;,
        &quot;📬 **Local &amp;amp; Outbound Delivery**&quot;,
        f&quot;• Dovecot (local mailboxes): **{deliv[&apos;local&apos;][&apos;sent&apos;]:,}** delivered&quot;,
        f&quot;• Direct SMTP (relays / MX): **{deliv[&apos;smtp&apos;][&apos;sent&apos;]:,}** delivered\n&quot;,
        &quot;💾 **System Health**&quot;,
        f&quot;• RAM: {sys_m[&apos;ram&apos;]}&quot;,
        f&quot;• ZRAM: {sys_m[&apos;zram&apos;]}&quot;,
        f&quot;• Load Average: {sys_m[&apos;load&apos;]}&quot;,
        f&quot;• Postfix Queue: {sys_m[&apos;queue&apos;]}&quot;,
        f&quot;• Disk: {sys_m[&apos;disk&apos;]}&quot;
    ]
    return &quot;\n&quot;.join(lines)

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument(&quot;--send&quot;, action=&quot;store_true&quot;)
    parser.add_argument(&quot;--dry-run&quot;, action=&quot;store_true&quot;)
    args = parser.parse_args()

    report = build_report()
    if args.send:
        req = urllib.request.Request(
            NTFY_URL,
            data=report.encode(&quot;utf-8&quot;),
            headers={&quot;Title&quot;: &quot;Chatmail: Daily Status Report&quot;, &quot;Tags&quot;: &quot;chart_with_upwards_trend,email&quot;},
            method=&quot;POST&quot;
        )
        urllib.request.urlopen(req, timeout=10)
        print(&quot;Sent to ntfy.&quot;)
    else:
        print(report)

if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Запуск по таймеру (&lt;code&gt;/etc/systemd/system/chatmail-status-report.timer&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Unit]
Description=Daily Chatmail status report timer

[Timer]
OnCalendar=*-*-* 09:00:00 UTC
Persistent=true

[Install]
WantedBy=timers.target
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;systemctl daemon-reload &amp;amp;&amp;amp; systemctl enable --now chatmail-status-report.timer
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;6. Результаты на живом сервере&lt;/h2&gt;
&lt;p&gt;Показатели на сервере &lt;code&gt;chatmail.uk&lt;/code&gt; через час работы:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;📊 **Chatmail UK: Daily Status &amp;amp; QoS Report**
Server: `chatmail.uk` | Period: Last 24 Hours

🚀 **Fast Lane (lmtp-filtermail, &amp;lt; 10 MB)**
• Delivered: **3,282** messages
• Latency: avg **16.2s** | p95 **49.0s** | max **64.0s**
• Issues: 86 deferred, 213 bounced

🐘 **Heavy Lane (lmtp-filtermail-heavy, ≥ 10 MB)**
• Heavy messages routed: **269** (sent: 260, deferred: 9)
• Status: Safe batching (25 rcpt limit), 0 OOM crashes

📬 **Local &amp;amp; Outbound Delivery**
• Dovecot (local mailboxes): **3,101** delivered
• Direct SMTP (relays / MX): **1,348** delivered

💾 **System Health**
• RAM: 880 MB / 1980 MB (1099 MB available)
• ZRAM: 315 KB (data: 1220 KB, ratio: 3.9x)
• Load Average: 0.56, 0.66, 0.52
• Postfix Queue: 83 messages
• Disk: 8.5 GB free of 19.5 GB
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Итог&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Мессенджер поверх SMTP работает отлично&lt;/strong&gt;, но базовые настройки почтовых демонов обычно рассчитаны на классическую редкую переписку, а не на чаты со множеством мульти-релей-аккаунтов и постоянные тяжелые медиа.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Линейное клонирование памяти в коде — узкое место.&lt;/strong&gt; Конструкция &lt;code&gt;envelope.clone()&lt;/code&gt; для каждого внешнего домена на больших вложениях легко роняет сервер с 2 ГБ RAM.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Двухскоростной QoS в Postfix полностью решает проблему.&lt;/strong&gt; Текст и команды ботов улетают мгновенно без очередей, а тяжелые вложения безопасно дозируются. В итоге сервер за $5/месяц спокойно держит нагрузку даже для крупного сообщества.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;P.S. Проблема №2 &lt;a href=&quot;https://github.com/chatmail/filtermail/commit/b6d05ef980a5fd71cc7f6f1a7b51584229f61563&quot;&gt;была решена в filtermail&lt;/a&gt; на следующий день после написания статьи. Так что скоро можно будет просто обновить &lt;a href=&quot;https://github.com/chatmail/relay&quot;&gt;relay&lt;/a&gt; через &lt;a href=&quot;https://chatmail.at/doc/relay/overview.html#cmdeploy&quot;&gt;cmdeploy run&lt;/a&gt;, но разделение очереди на две части я всё равно считаю полезным, особенно, если на сервере запущены боты типа моего &lt;a href=&quot;https://git.gluek.info/gluek/deltachat_telegram_bridge&quot;&gt;TG Bridge&lt;/a&gt;.&lt;/p&gt;
</content:encoded></item><item><title>Как боты съели 70 ГБ на моём сервере за 20 часов</title><link>https://gluek.info/blog/kak-boty-seli-70-gb/</link><guid isPermaLink="true">https://gluek.info/blog/kak-boty-seli-70-gb/</guid><description>Неожиданные грабли селфхостинга Forgejo: распределённый скрапинг публичных зеркал и бесконечный кэш repo-archive.</description><pubDate>Tue, 08 Sep 2026 00:25:00 GMT</pubDate><content:encoded>&lt;p&gt;Вчера у меня на сервере внезапно кончилось вообще всё свободное место (ровно в 0 байт), а боты начали падать с &lt;code&gt;disk I/O error&lt;/code&gt; из-за невозможности записать даже байт в SQLite.&lt;/p&gt;
&lt;p&gt;Полез разбираться с &lt;code&gt;ncdu&lt;/code&gt; и быстро нашёл виновника: папка &lt;code&gt;/root/forgejo/data/gitea/repo-archive/&lt;/code&gt; в контейнере &lt;a href=&quot;https://forgejo.org/&quot;&gt;Forgejo&lt;/a&gt; разрослась на &lt;strong&gt;70+ ГБ&lt;/strong&gt;. Внутри лежали сотни сгенерированных ZIP-архивов по 300–500 МБ каждый.&lt;/p&gt;
&lt;h3&gt;Что произошло?&lt;/h3&gt;
&lt;p&gt;Когда в веб-интерфейсе запрашивают архив релиза или ветки (&lt;code&gt;/archive/tag.zip&lt;/code&gt;), Forgejo на лету пакует его и сохраняет копию в кэш.&lt;/p&gt;
&lt;p&gt;Какой-то распределённый AI-краулер на резидентных прокси (IP со всего мира от Бразилии до Вьетнама) решил методично пройтись по всем тегам и коммитам в моём публичном зеркале крупного опенсорс-репозитория. На каждый чих бота Forgejo честно генерировал и складывал на диск полугигабайтный архив (стабильно держа около 15% CPU).&lt;/p&gt;
&lt;p&gt;Встроенный крон очистки в Forgejo по умолчанию срабатывает раз в сутки в полночь (&lt;code&gt;@midnight&lt;/code&gt;), удаляя архивы старше 24 часов. За 20 часов нагенерировалось 70 ГБ, и до полуночи сервер не дожил буквально час.&lt;/p&gt;
&lt;h3&gt;Как лечить&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Экстренная очистка&lt;/strong&gt; (безопасно, на сам Git не влияет):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rm -rf /root/forgejo/data/gitea/repo-archive/*
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Закрыть публичный доступ к зеркалам&lt;/strong&gt;:
Бороться с распределённым ботнетом через &lt;code&gt;fail2ban&lt;/code&gt; или &lt;code&gt;robots.txt&lt;/code&gt; бесполезно. Самое надёжное — перевести аккаунт &lt;code&gt;mirrors&lt;/code&gt; в режим Limited (только для авторизованных пользователей). Бот упирается в форму логина за 1 мс без какой-либо нагрузки на диск.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Зарезать генерацию архивов в &lt;code&gt;app.ini&lt;/code&gt;&lt;/strong&gt;:
Если архивы для репозиториев вообще не нужны:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[repository]
DISABLE_DOWNLOAD_SOURCE_ARCHIVES = true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Либо чистить их кэш каждый час:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[cron.archive_cleanup]
SCHEDULE = @every 1h
OLDER_THAN = 2h
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Теория мёртвого интернета на практике&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Dead_Internet_theory&quot;&gt;Теория мёртвого интернета&lt;/a&gt; перестаёт казаться просто забавной конспирологией, когда сталкиваешься с ней на собственном сервере: чужие боты на резидентных прокси гоняют мой софт, заставляя его паковать сотни гигабайт архивов, которые ни один живой человек в жизни не откроет.&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;
</content:encoded></item><item><title>t.gluek.info</title><link>https://gluek.info/projects/t-gluek-info/</link><guid isPermaLink="true">https://gluek.info/projects/t-gluek-info/</guid><description>Персональный легковесный редиректор Telegram-ссылок через Caddy без блокировок и трекеров</description><pubDate>Tue, 08 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Персональный легковесный редиректор ссылок в Telegram, работающий на собственном домене &lt;code&gt;t.gluek.info&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Служит независимой альтернативой официальному шортлинкеру &lt;code&gt;t.me&lt;/code&gt;, помогая открывать каналы, чаты, посты, стикеры и настройки в условиях региональных или провайдерских блокировок домена &lt;code&gt;t.me&lt;/code&gt;, не нагружая браузер лишними скриптами и внешними трекерами.&lt;/p&gt;
&lt;h2&gt;Проблема&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Блокировки домена &lt;code&gt;t.me&lt;/code&gt;&lt;/strong&gt;: В некоторых сетях и у отдельных провайдеров домен &lt;code&gt;t.me&lt;/code&gt; подвергается блокировкам или искусственному замедлению. В результате пользователи не могут открыть канал или перейти по ссылке.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ограничения deep link (&lt;code&gt;tg://&lt;/code&gt;)&lt;/strong&gt;: Прямой HTTP-редирект (301/302) на кастомную схему &lt;code&gt;tg://resolve?...&lt;/code&gt; блокируется большинством современных мобильных и десктопных браузеров (Chrome, Safari) с ошибкой &lt;code&gt;ERR_UNKNOWN_URL_SCHEME&lt;/code&gt;, если переход инициирован заголовком ответа, а не явным кликом пользователя.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Мусорный трафик и сканеры&lt;/strong&gt;: При использовании неразборчивого catch-all обработчика боты и сканеры уязвимостей (&lt;code&gt;/.env&lt;/code&gt;, &lt;code&gt;/wp-admin&lt;/code&gt;) получали бы &lt;code&gt;200 OK&lt;/code&gt; и пытались генерировать фиктивные ссылки.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Архитектура решения&lt;/h2&gt;
&lt;p&gt;Сервис работает &lt;strong&gt;полностью на уровне веб-сервера &lt;a href=&quot;https://caddyserver.com/&quot;&gt;Caddy&lt;/a&gt;&lt;/strong&gt; без единой строчки бэкенд-кода (Node.js, Go, Python или PHP не требуются).&lt;/p&gt;
&lt;h3&gt;Преимущества серверной маршрутизации в Caddy&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Строгая валидация путей (Whitelist)&lt;/strong&gt;: Caddy проверяет пути регулярными выражениями. Любые посторонние запросы, сканеры ботов или битые URL сразу отсекаются и отправляются на страницу проекта или 404, не запуская клиентский код.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Серверные мета-теги (SSR)&lt;/strong&gt;: Заголовки страницы (&lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;) и прямой URL для кнопки формируются сервером на этапе отдачи страницы через плейсхолдеры Caddy (&lt;code&gt;{re.user.1}&lt;/code&gt;, &lt;code&gt;{re.post.1}&lt;/code&gt; и т.д.).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Мгновенный переход + фоллбэк&lt;/strong&gt;: JavaScript в браузере сразу инициирует открытие Telegram, а если браузер запросил подтверждение — пользователь видит карточку с чёткой кнопкой прямого перехода (&lt;strong&gt;Open in Telegram&lt;/strong&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Векторная фавиконка в стиле Telegram&lt;/strong&gt;: Caddy самостоятельно отдаёт легковесную SVG-фавиконку для &lt;code&gt;/favicon.ico&lt;/code&gt; и &lt;code&gt;/favicon.svg&lt;/code&gt; без графических файлов на диске.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Интернациональный интерфейс&lt;/strong&gt;: Все карточки оформлены на английском языке в лаконичном стиле &lt;code&gt;t.me&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Поддерживаемые форматы ссылок&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Формат &lt;code&gt;t.gluek.info&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Схема &lt;code&gt;tg://&lt;/code&gt; в приложении&lt;/th&gt;
&lt;th&gt;Назначение&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/Gluek&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tg://resolve?domain=Gluek&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Профиль, публичный канал или бот&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/my_bot?start=ref123&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tg://resolve?domain=my_bot&amp;amp;start=ref123&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Бот с параметрами запуска&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/gluekinfo/123&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tg://resolve?domain=gluekinfo&amp;amp;post=123&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Пост в публичном канале&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/c/1234567890/42&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tg://privatepost?channel=1234567890&amp;amp;post=42&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Пост в закрытом канале&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/group/12/34&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tg://resolve?domain=group&amp;amp;thread=12&amp;amp;post=34&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Сообщение в ветке форума&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;+inviteCode&lt;/code&gt; / &lt;code&gt;/joinchat/...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tg://join?invite=inviteCode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Приглашение в чат или канал&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;+79991234567&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tg://resolve?phone=79991234567&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Чат по номеру телефона&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/addstickers/pack_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tg://addstickers?set=pack_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Установка стикерпака&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/addemoji/pack_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tg://addemoji?set=pack_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Установка кастомных эмодзи&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/addlist/list_hash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tg://addlist?slug=list_hash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Добавление папки с чатами&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/proxy?...&lt;/code&gt; / &lt;code&gt;/socks?...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tg://proxy?...&lt;/code&gt; / &lt;code&gt;tg://socks?...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Классические MTProto / SOCKS5 прокси&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/webproxy?...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tg://webproxy?...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Новый экспериментальный WEB Proxy (WebSocket/HTTPS)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Конфигурация Caddyfile&lt;/h2&gt;
&lt;p&gt;Ниже приведена полная конфигурация виртуального хоста для &lt;code&gt;Caddyfile&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;t.gluek.info {
    # Фавиконка в стиле Telegram (SVG)
    @favicon path /favicon.ico /favicon.svg
    handle @favicon {
        header Content-Type &quot;image/svg+xml&quot;
        header Cache-Control &quot;public, max-age=604800&quot;
        respond &amp;lt;&amp;lt;SVG
&amp;lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; viewBox=&quot;0 0 24 24&quot;&amp;gt;&amp;lt;circle cx=&quot;12&quot; cy=&quot;12&quot; r=&quot;12&quot; fill=&quot;#24A1DE&quot;/&amp;gt;&amp;lt;path fill=&quot;#ffffff&quot; d=&quot;M5.4 12l11.6-4.5c.5-.2 1 .1.9.7l-2 9.4c-.1.6-.6.8-1.1.5l-3.3-2.4-1.6 1.5c-.2.2-.4.3-.7.3l.2-3.4 6.2-5.6c.3-.3-.1-.4-.4-.2l-7.7 4.9-3.3-1c-.7-.2-.7-.7.2-1z&quot;/&amp;gt;&amp;lt;/svg&amp;gt;
SVG 200
    }

    # Общие заголовки
    header Content-Type &quot;text/html; charset=utf-8&quot;
    header Cache-Control &quot;no-cache&quot;

    # 1. Приватные посты в закрытых каналах: /c/1234567890/42
    @private_post path_regexp priv ^/c/([0-9]+)/([0-9]+)/?$
    handle @private_post {
        respond &amp;lt;&amp;lt;HTML
&amp;lt;!DOCTYPE html&amp;gt;&amp;lt;html lang=&quot;en&quot;&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta charset=&quot;utf-8&quot;&amp;gt;&amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;&amp;lt;title&amp;gt;Private Channel Post — t.gluek.info&amp;lt;/title&amp;gt;&amp;lt;link rel=&quot;icon&quot; href=&quot;/favicon.svg&quot; type=&quot;image/svg+xml&quot;&amp;gt;&amp;lt;style&amp;gt;body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f172a;color:#fff;text-align:center}.card{background:#1e293b;padding:2rem;border-radius:1rem;border:1px solid #334155;max-width:380px;width:90%}.btn{display:block;margin-top:1.5rem;background:#24A1DE;color:#fff;text-decoration:none;padding:.8rem;border-radius:.5rem;font-weight:600}.footer{margin-top:1.5rem;font-size:.8rem;color:#94a3b8}.footer a{color:#94a3b8}&amp;lt;/style&amp;gt;&amp;lt;script&amp;gt;window.location.href=&quot;tg://privatepost?channel={re.priv.1}&amp;amp;post={re.priv.2}&quot;;&amp;lt;/script&amp;gt;&amp;lt;/head&amp;gt;&amp;lt;body&amp;gt;&amp;lt;div class=&quot;card&quot;&amp;gt;&amp;lt;h2&amp;gt;Private Channel&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Opening post #{re.priv.2}...&amp;lt;/p&amp;gt;&amp;lt;a class=&quot;btn&quot; href=&quot;tg://privatepost?channel={re.priv.1}&amp;amp;post={re.priv.2}&quot;&amp;gt;Open in Telegram&amp;lt;/a&amp;gt;&amp;lt;div class=&quot;footer&quot;&amp;gt;Redirector &amp;lt;a href=&quot;https://gluek.info/projects/t-gluek-info/&quot;&amp;gt;t.gluek.info&amp;lt;/a&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;
HTML 200
    }

    # 2. Стикеры, эмодзи и папки: /addstickers/pack, /addemoji/pack, /addlist/hash
    @packs path_regexp packs ^/(addstickers|addemoji|addlist)/([a-zA-Z0-9_]+)/?$
    handle @packs {
        respond &amp;lt;&amp;lt;HTML
&amp;lt;!DOCTYPE html&amp;gt;&amp;lt;html lang=&quot;en&quot;&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta charset=&quot;utf-8&quot;&amp;gt;&amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;&amp;lt;title&amp;gt;Telegram {re.packs.1} — t.gluek.info&amp;lt;/title&amp;gt;&amp;lt;link rel=&quot;icon&quot; href=&quot;/favicon.svg&quot; type=&quot;image/svg+xml&quot;&amp;gt;&amp;lt;style&amp;gt;body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f172a;color:#fff;text-align:center}.card{background:#1e293b;padding:2rem;border-radius:1rem;border:1px solid #334155;max-width:380px;width:90%}.btn{display:block;margin-top:1.5rem;background:#24A1DE;color:#fff;text-decoration:none;padding:.8rem;border-radius:.5rem;font-weight:600}.footer{margin-top:1.5rem;font-size:.8rem;color:#94a3b8}.footer a{color:#94a3b8}&amp;lt;/style&amp;gt;&amp;lt;script&amp;gt;
const type = &quot;{re.packs.1}&quot;;
const param = type === &quot;addlist&quot; ? &quot;slug&quot; : &quot;set&quot;;
window.location.href = `tg://${type}?${param}={re.packs.2}`;
&amp;lt;/script&amp;gt;&amp;lt;/head&amp;gt;&amp;lt;body&amp;gt;&amp;lt;div class=&quot;card&quot;&amp;gt;&amp;lt;h2&amp;gt;Add to Telegram&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Installing {re.packs.2}...&amp;lt;/p&amp;gt;&amp;lt;a class=&quot;btn&quot; href=&quot;tg://{re.packs.1}?set={re.packs.2}&quot;&amp;gt;Install in Telegram&amp;lt;/a&amp;gt;&amp;lt;div class=&quot;footer&quot;&amp;gt;Redirector &amp;lt;a href=&quot;https://gluek.info/projects/t-gluek-info/&quot;&amp;gt;t.gluek.info&amp;lt;/a&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;
HTML 200
    }

    # 3. Настройки прокси (MTProto, SOCKS5, WEB Proxy): /proxy?... /socks?... /webproxy?...
    @proxy path_regexp proxy ^/(proxy|socks|webproxy)/?$
    handle @proxy {
        respond &amp;lt;&amp;lt;HTML
&amp;lt;!DOCTYPE html&amp;gt;&amp;lt;html lang=&quot;en&quot;&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta charset=&quot;utf-8&quot;&amp;gt;&amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;&amp;lt;title&amp;gt;Telegram Proxy — t.gluek.info&amp;lt;/title&amp;gt;&amp;lt;link rel=&quot;icon&quot; href=&quot;/favicon.svg&quot; type=&quot;image/svg+xml&quot;&amp;gt;&amp;lt;style&amp;gt;body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f172a;color:#fff;text-align:center}.card{background:#1e293b;padding:2rem;border-radius:1rem;border:1px solid #334155;max-width:380px;width:90%}.btn{display:block;margin-top:1.5rem;background:#24A1DE;color:#fff;text-decoration:none;padding:.8rem;border-radius:.5rem;font-weight:600}.footer{margin-top:1.5rem;font-size:.8rem;color:#94a3b8}.footer a{color:#94a3b8}&amp;lt;/style&amp;gt;&amp;lt;script&amp;gt;window.location.href=&quot;tg://{re.proxy.1}?{query}&quot;;&amp;lt;/script&amp;gt;&amp;lt;/head&amp;gt;&amp;lt;body&amp;gt;&amp;lt;div class=&quot;card&quot;&amp;gt;&amp;lt;h2&amp;gt;Connect to Proxy&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Applying {re.proxy.1} settings...&amp;lt;/p&amp;gt;&amp;lt;a class=&quot;btn&quot; href=&quot;tg://{re.proxy.1}?{query}&quot;&amp;gt;Connect in Telegram&amp;lt;/a&amp;gt;&amp;lt;div class=&quot;footer&quot;&amp;gt;Redirector &amp;lt;a href=&quot;https://gluek.info/projects/t-gluek-info/&quot;&amp;gt;t.gluek.info&amp;lt;/a&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;
HTML 200
    }

    # 4. Ссылка на телефон: /+79991234567
    @phone path_regexp phone ^/\+([0-9]{7,15})/?$
    handle @phone {
        respond &amp;lt;&amp;lt;HTML
&amp;lt;!DOCTYPE html&amp;gt;&amp;lt;html lang=&quot;en&quot;&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta charset=&quot;utf-8&quot;&amp;gt;&amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;&amp;lt;title&amp;gt;Telegram Contact — t.gluek.info&amp;lt;/title&amp;gt;&amp;lt;link rel=&quot;icon&quot; href=&quot;/favicon.svg&quot; type=&quot;image/svg+xml&quot;&amp;gt;&amp;lt;style&amp;gt;body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f172a;color:#fff;text-align:center}.card{background:#1e293b;padding:2rem;border-radius:1rem;border:1px solid #334155;max-width:380px;width:90%}.btn{display:block;margin-top:1.5rem;background:#24A1DE;color:#fff;text-decoration:none;padding:.8rem;border-radius:.5rem;font-weight:600}.footer{margin-top:1.5rem;font-size:.8rem;color:#94a3b8}.footer a{color:#94a3b8}&amp;lt;/style&amp;gt;&amp;lt;script&amp;gt;window.location.href=&quot;tg://resolve?phone={re.phone.1}&quot;;&amp;lt;/script&amp;gt;&amp;lt;/head&amp;gt;&amp;lt;body&amp;gt;&amp;lt;div class=&quot;card&quot;&amp;gt;&amp;lt;h2&amp;gt;Contact&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Opening chat with +{re.phone.1}...&amp;lt;/p&amp;gt;&amp;lt;a class=&quot;btn&quot; href=&quot;tg://resolve?phone={re.phone.1}&quot;&amp;gt;Open in Telegram&amp;lt;/a&amp;gt;&amp;lt;div class=&quot;footer&quot;&amp;gt;Redirector &amp;lt;a href=&quot;https://gluek.info/projects/t-gluek-info/&quot;&amp;gt;t.gluek.info&amp;lt;/a&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;
HTML 200
    }

    # 5. Темы форумов (topic + post): /group/12/34
    @topic path_regexp topic ^/([a-zA-Z0-9_]+)/([0-9]+)/([0-9]+)/?$
    handle @topic {
        respond &amp;lt;&amp;lt;HTML
&amp;lt;!DOCTYPE html&amp;gt;&amp;lt;html lang=&quot;en&quot;&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta charset=&quot;utf-8&quot;&amp;gt;&amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;&amp;lt;title&amp;gt;Topic in @{re.topic.1} — t.gluek.info&amp;lt;/title&amp;gt;&amp;lt;link rel=&quot;icon&quot; href=&quot;/favicon.svg&quot; type=&quot;image/svg+xml&quot;&amp;gt;&amp;lt;style&amp;gt;body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f172a;color:#fff;text-align:center}.card{background:#1e293b;padding:2rem;border-radius:1rem;border:1px solid #334155;max-width:380px;width:90%}.btn{display:block;margin-top:1.5rem;background:#24A1DE;color:#fff;text-decoration:none;padding:.8rem;border-radius:.5rem;font-weight:600}.footer{margin-top:1.5rem;font-size:.8rem;color:#94a3b8}.footer a{color:#94a3b8}&amp;lt;/style&amp;gt;&amp;lt;script&amp;gt;window.location.href=&quot;tg://resolve?domain={re.topic.1}&amp;amp;thread={re.topic.2}&amp;amp;post={re.topic.3}&quot;;&amp;lt;/script&amp;gt;&amp;lt;/head&amp;gt;&amp;lt;body&amp;gt;&amp;lt;div class=&quot;card&quot;&amp;gt;&amp;lt;h2&amp;gt;Topic in @{re.topic.1}&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Opening discussion thread...&amp;lt;/p&amp;gt;&amp;lt;a class=&quot;btn&quot; href=&quot;tg://resolve?domain={re.topic.1}&amp;amp;thread={re.topic.2}&amp;amp;post={re.topic.3}&quot;&amp;gt;Open in Telegram&amp;lt;/a&amp;gt;&amp;lt;div class=&quot;footer&quot;&amp;gt;Redirector &amp;lt;a href=&quot;https://gluek.info/projects/t-gluek-info/&quot;&amp;gt;t.gluek.info&amp;lt;/a&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;
HTML 200
    }

    # 6. Пост в канале: /Gluek/123
    @post path_regexp post ^/([a-zA-Z0-9_]+)/([0-9]+)/?$
    handle @post {
        respond &amp;lt;&amp;lt;HTML
&amp;lt;!DOCTYPE html&amp;gt;&amp;lt;html lang=&quot;en&quot;&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta charset=&quot;utf-8&quot;&amp;gt;&amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;&amp;lt;title&amp;gt;Post in @{re.post.1} — t.gluek.info&amp;lt;/title&amp;gt;&amp;lt;link rel=&quot;icon&quot; href=&quot;/favicon.svg&quot; type=&quot;image/svg+xml&quot;&amp;gt;&amp;lt;style&amp;gt;body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f172a;color:#fff;text-align:center}.card{background:#1e293b;padding:2rem;border-radius:1rem;border:1px solid #334155;max-width:380px;width:90%}.btn{display:block;margin-top:1.5rem;background:#24A1DE;color:#fff;text-decoration:none;padding:.8rem;border-radius:.5rem;font-weight:600}.footer{margin-top:1.5rem;font-size:.8rem;color:#94a3b8}.footer a{color:#94a3b8}&amp;lt;/style&amp;gt;&amp;lt;script&amp;gt;window.location.href=&quot;tg://resolve?domain={re.post.1}&amp;amp;post={re.post.2}&quot;;&amp;lt;/script&amp;gt;&amp;lt;/head&amp;gt;&amp;lt;body&amp;gt;&amp;lt;div class=&quot;card&quot;&amp;gt;&amp;lt;h2&amp;gt;Post in @{re.post.1}&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Opening post #{re.post.2}...&amp;lt;/p&amp;gt;&amp;lt;a class=&quot;btn&quot; href=&quot;tg://resolve?domain={re.post.1}&amp;amp;post={re.post.2}&quot;&amp;gt;Open in Telegram&amp;lt;/a&amp;gt;&amp;lt;div class=&quot;footer&quot;&amp;gt;Redirector &amp;lt;a href=&quot;https://gluek.info/projects/t-gluek-info/&quot;&amp;gt;t.gluek.info&amp;lt;/a&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;
HTML 200
    }

    # 7. Инвайты: /+inviteHash или /joinchat/inviteHash
    @invite path_regexp invite ^/(?:\+|joinchat/)([a-zA-Z0-9_-]+)/?$
    handle @invite {
        respond &amp;lt;&amp;lt;HTML
&amp;lt;!DOCTYPE html&amp;gt;&amp;lt;html lang=&quot;en&quot;&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta charset=&quot;utf-8&quot;&amp;gt;&amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;&amp;lt;title&amp;gt;Telegram Invite — t.gluek.info&amp;lt;/title&amp;gt;&amp;lt;link rel=&quot;icon&quot; href=&quot;/favicon.svg&quot; type=&quot;image/svg+xml&quot;&amp;gt;&amp;lt;style&amp;gt;body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f172a;color:#fff;text-align:center}.card{background:#1e293b;padding:2rem;border-radius:1rem;border:1px solid #334155;max-width:380px;width:90%}.btn{display:block;margin-top:1.5rem;background:#24A1DE;color:#fff;text-decoration:none;padding:.8rem;border-radius:.5rem;font-weight:600}.footer{margin-top:1.5rem;font-size:.8rem;color:#94a3b8}.footer a{color:#94a3b8}&amp;lt;/style&amp;gt;&amp;lt;script&amp;gt;window.location.href=&quot;tg://join?invite={re.invite.1}&quot;;&amp;lt;/script&amp;gt;&amp;lt;/head&amp;gt;&amp;lt;body&amp;gt;&amp;lt;div class=&quot;card&quot;&amp;gt;&amp;lt;h2&amp;gt;Telegram Invite&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Opening invitation link...&amp;lt;/p&amp;gt;&amp;lt;a class=&quot;btn&quot; href=&quot;tg://join?invite={re.invite.1}&quot;&amp;gt;Join in Telegram&amp;lt;/a&amp;gt;&amp;lt;div class=&quot;footer&quot;&amp;gt;Redirector &amp;lt;a href=&quot;https://gluek.info/projects/t-gluek-info/&quot;&amp;gt;t.gluek.info&amp;lt;/a&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;
HTML 200
    }

    # 8. Пользователь / канал / бот: /Gluek?start=123
    @user path_regexp user ^/([a-zA-Z0-9_]+)/?$
    handle @user {
        respond &amp;lt;&amp;lt;HTML
&amp;lt;!DOCTYPE html&amp;gt;&amp;lt;html lang=&quot;en&quot;&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta charset=&quot;utf-8&quot;&amp;gt;&amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;&amp;lt;title&amp;gt;@{re.user.1} — t.gluek.info&amp;lt;/title&amp;gt;&amp;lt;link rel=&quot;icon&quot; href=&quot;/favicon.svg&quot; type=&quot;image/svg+xml&quot;&amp;gt;&amp;lt;style&amp;gt;body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f172a;color:#fff;text-align:center}.card{background:#1e293b;padding:2rem;border-radius:1rem;border:1px solid #334155;max-width:380px;width:90%}.btn{display:block;margin-top:1.5rem;background:#24A1DE;color:#fff;text-decoration:none;padding:.8rem;border-radius:.5rem;font-weight:600}.footer{margin-top:1.5rem;font-size:.8rem;color:#94a3b8}.footer a{color:#94a3b8}&amp;lt;/style&amp;gt;&amp;lt;/head&amp;gt;&amp;lt;body&amp;gt;&amp;lt;div class=&quot;card&quot;&amp;gt;&amp;lt;h2&amp;gt;@{re.user.1}&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;Opening channel or profile...&amp;lt;/p&amp;gt;&amp;lt;a id=&quot;btn&quot; class=&quot;btn&quot; href=&quot;tg://resolve?domain={re.user.1}&quot;&amp;gt;Open in Telegram&amp;lt;/a&amp;gt;&amp;lt;div class=&quot;footer&quot;&amp;gt;Redirector &amp;lt;a href=&quot;https://gluek.info/projects/t-gluek-info/&quot;&amp;gt;t.gluek.info&amp;lt;/a&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;script&amp;gt;
const query = &quot;{query}&quot; ? &quot;&amp;amp;{query}&quot; : &quot;&quot;;
const tgUrl = &quot;tg://resolve?domain={re.user.1}&quot; + query;
const btn = document.getElementById(&quot;btn&quot;);
if (btn) btn.href = tgUrl;
window.location.href = tgUrl;
&amp;lt;/script&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;
HTML 200
    }

    # 9. Всё остальное (корень или невалидные пути) -&amp;gt; на описание проекта
    handle {
        redir https://gluek.info/projects/t-gluek-info/ 302
    }
}
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>git.gluek.info</title><link>https://gluek.info/projects/git-gluek-info/</link><guid isPermaLink="true">https://gluek.info/projects/git-gluek-info/</guid><description>Мой персональный Git-сервис на Forgejo с встроенным CI/CD (Forgejo Actions) и зеркалами репозиториев</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Собственный легковесный Git-сервис на базе &lt;strong&gt;&lt;a href=&quot;https://forgejo.org/&quot;&gt;Forgejo&lt;/a&gt;&lt;/strong&gt; — свободного, открытого и управляемого сообществом форка Gitea. Сервис развернут на личном VPS и служит единым источником правды (&lt;strong&gt;Single Source of Truth&lt;/strong&gt;) для репозиториев, исходного кода сайта &lt;code&gt;gluek.info&lt;/code&gt;, скриптов автоматизации и вспомогательных ботов.&lt;/p&gt;
&lt;p&gt;Проект полностью укладывается в философию &lt;strong&gt;IndieWeb / POSSE&lt;/strong&gt;: владение собственными данными, независимость от коммерческих Git-платформ и бесшовная интеграция с персональной инфраструктурой.&lt;/p&gt;
&lt;h2&gt;Стек&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://forgejo.org/&quot;&gt;Forgejo&lt;/a&gt;&lt;/strong&gt; в &lt;a href=&quot;https://www.docker.com/&quot;&gt;Docker&lt;/a&gt;-контейнере&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;a href=&quot;https://forgejo.org/docs/latest/user/actions/&quot;&gt;Forgejo Actions&lt;/a&gt;&lt;/strong&gt; (&lt;a href=&quot;https://gitea.com/gitea/act_runner&quot;&gt;act runner&lt;/a&gt; в Docker) для сборки и автоматизации&lt;/li&gt;
&lt;li&gt;REST API для внешних интеграций&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Как я это использую&lt;/h2&gt;
&lt;h3&gt;1. Автоматический деплой сайта и кросспостинг&lt;/h3&gt;
&lt;p&gt;Все коммиты в ветку &lt;code&gt;main&lt;/code&gt; блога автоматически подхватываются раннером:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Собирается статический сайт на &lt;a href=&quot;https://astro.build/&quot;&gt;Astro&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Подтягивается кэш npm и сгенерированных OG-картинок (&lt;code&gt;.og-cache&lt;/code&gt;), так что пересборка 1300+ постов занимает буквально считанные секунды.&lt;/li&gt;
&lt;li&gt;Готовый &lt;code&gt;dist/&lt;/code&gt; копируется в директорию Caddy на сервере.&lt;/li&gt;
&lt;li&gt;Скрипты проверяют свежие посты и автоматом закидывают анонсы в мой Telegram-канал (&lt;a href=&quot;https://t.gluek.info/gluekinfo&quot;&gt;@gluekinfo&lt;/a&gt;) и Fediverse (&lt;a href=&quot;https://so.gluek.info/@gluek&quot;&gt;@gluek@so.gluek.info&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. Постинг со смартфона в один коммит&lt;/h3&gt;
&lt;p&gt;Через REST API инстанс связан с моим ботом &lt;a href=&quot;https://gluek.info/projects/deltachat-publish/&quot;&gt;Delta Chat Publish&lt;/a&gt;: я могу набросать заметку с фотографиями прямо в мессенджере на телефоне, а бот отправит всё одним запросом через &lt;code&gt;ChangeFilesOptions&lt;/code&gt; — ровно один коммит, один триггер пайплайна, и через минуту пост на сайте.&lt;/p&gt;
&lt;h3&gt;3. Зеркала репозиториев (&lt;code&gt;mirrors&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;В отдельный аккаунт &lt;strong&gt;&lt;code&gt;mirrors&lt;/code&gt;&lt;/strong&gt; я настроил зеркалирование внешних репозиториев и зависимостей, которые использую в своих проектах — чтобы они всегда были под рукой локально на случай, если с оригинальным репозиторием что-то случится.&lt;/p&gt;
&lt;p&gt;Из-за постоянного наплыва ботов и скрейперов аккаунт &lt;code&gt;mirrors&lt;/code&gt; сейчас закрыт от анонимного просмотра и доступен только залогиненным пользователям. Если вам нужен доступ — просто скажите мне, и я сделаю аккаунт.&lt;/p&gt;
&lt;h3&gt;4. Автономность&lt;/h3&gt;
&lt;p&gt;Свои секреты в репозиториях, SSH-ключи, двухфакторка и полный контроль над своими данными без лишнего трекинга и корпоративного контроля.&lt;/p&gt;
</content:encoded></item><item><title>Сколько вешать в .gram-ах?</title><link>https://gluek.info/blog/skolko-veshat-v-gram-akh/</link><guid isPermaLink="true">https://gluek.info/blog/skolko-veshat-v-gram-akh/</guid><description>Дуров всё-таки сделал выводы после блокировки t.me и анонсировал, что Telegram подал заявку на доменную зону .gram. Обещают каждому пользователю до...</description><pubDate>Tue, 18 Aug 2026 20:11:48 GMT</pubDate><content:encoded>&lt;p&gt;Дуров всё-таки сделал выводы после блокировки t.me и анонсировал, что Telegram подал заявку на доменную зону .gram. Обещают каждому пользователю до домену вида gluek.gram и возможность быстро запустить там некий &quot;интерактивный сайт&quot;. А с другой стороны и заблокировать будет проще.&lt;/p&gt;
</content:encoded></item><item><title>Google Pixel Tag</title><link>https://gluek.info/blog/google-pixel-tag/</link><guid isPermaLink="true">https://gluek.info/blog/google-pixel-tag/</guid><description>Отдельно впечатление про их тег - и поздно, и дорого. Вроде есть поддежка Ultra-Wideband для точного поиска - но она была и у Moto-тегов за те же д...</description><pubDate>Wed, 12 Aug 2026 22:04:24 GMT</pubDate><content:encoded>&lt;p&gt;Отдельно впечатление про их тег - и поздно, и дорого. Вроде есть поддежка Ultra-Wideband для точного поиска - но она была и у Moto-тегов за те же деньги и пару лет назад, и они хоть по размеру совпадали с Air-тегами от Apple. А тут выходит и размер не такой, и по цене как 4 штуки от Ugreen, и офлайн этот весь поиск полностью не работоспособен становится.&lt;/p&gt;
&lt;p&gt;Как кто-то написал в комментариях - в ноябре они его выпустят, в декабре про него забудут.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://gluek.info/blog/google-pixel-tag/566751283e558e7fb818daeec7a30ec.webp&quot; alt=&quot;566751283e558e7fb818daeec7a30ec.webp&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>Google Pixel 11 &amp; Android 17</title><link>https://gluek.info/blog/google-pixel-11-android-17/</link><guid isPermaLink="true">https://gluek.info/blog/google-pixel-11-android-17/</guid><description>Мне кажется вообще ничего нового не появилось ни в самом телефоне, ни в новой версии андроида (которая уже пришла на мой Pixel 10 Pro XL - буквальн...</description><pubDate>Wed, 12 Aug 2026 21:31:29 GMT</pubDate><content:encoded>&lt;p&gt;Мне кажется вообще ничего нового не появилось ни в самом телефоне, ни в новой версии андроида (которая уже пришла на мой Pixel 10 Pro XL - буквально App Bubbles... и всё?), просто минимум смысла обновляться, хоть и сделали нормальный оффер:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Trade-in
Google Pixel 10 Pro XL (512GB)
£770.00 estimated value, applied instantly
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;P.S. С бонусом £150 за раннее бронирование вышло бы £479 за Pixel 11 Pro XL Obsidian (matte) 512GB (Unlocked)&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://gluek.info/blog/google-pixel-11-android-17/f1ccd85292433dc5f4c4e7e2b268721.webp&quot; alt=&quot;f1ccd85292433dc5f4c4e7e2b268721.webp&quot; /&gt;&lt;/p&gt;
</content:encoded></item></channel></rss>