Files
icq/WALKTHROUGH.md

55 KiB
Raw Permalink Blame History

Развёртывание XMPP-мессенджера ICQ на nixg.ru — Полный разбор (WALKTHROUGH)

Документ опыта. Дата последнего обновления: 2026-08-28. Команда: bigbox (внутренний сервер, /opt/icq) + vps02 (публичный вход, Caddy + WireGuard + DNAT). Домен: nixg.ru (JID юзеров user@nixg.ru) · Протокол: XMPP · Сервер: Prosody 0.11.9 (Docker). Веб-клиент: Converse.js v14.0.0 на https://chat.nixg.ru · WebSocket: wss://xmpp.nixg.ru См. также: STATUS.md — текущее состояние и TODO · PRD.md — требования/архитектура · MIGRATION.md — переезд chat.nixg.ru → nixg.ru.


1. Почему XMPP, а не Telegram/Matrix/Snikket

  • XMPP — открытый федеративный протокол (XML, RFC 6120/6121), как email: у каждого пользователя JID user@домен, серверы общаются между собой по s2s (порт 5269). Главное требование семьи — «общаться со всеми» — выполняется федерацией.
  • Telegram — закрытый централизованный протокол (MTProto, бинарный). Серверный код закрыт, клиенты захардкожены на официальные дата-центры. «Слямзить» протокол невозможно. Bot API — только HTTP+JSON поверх MTProto, федерации нет.
  • Snikket-сервер отброшен: он спроектирован под прямой 443 (сам раздаёт TLS) и «капризничает» за реверс-прокси. Его КЛИЕНТЫ (iOS/Android) — отличные, используем их.
  • Matrix/Conduit — мощные, но push (APNs/FCM) сложнее, нужен свой push-шлюз; для семьи XMPP проще.

2. Архитектура (почему так)

[Клиенты iOS/Android]  ←  c2s:5222 (TLS/LE), wss:443 (веб), s2s:5269 (федерация)
        │
        ▼
[vps02: 87.242.100.206]  ← публичный IP, Caddy (TLS/443) + iptables DNAT (5222/5269/5281)
        │                    WireGuard-туннель: bigbox=10.8.0.2, vps02=10.8.0.4
        ▼
[bigbox: 10.8.0.2]      ← внутри, /opt/icq, Docker Compose
   ├─ icq-prosody  — Prosody 0.11.9 (5222 c2s, 5269 s2s, 5280 http, 5281 https)
   ├─ icq-webchat  — nginx (8081): статика Converse.js + прокси WebSocket на Prosody
   └─ (отдельно на vps02) Caddy — 443: chat/xmpp/upload.nixg.ru → 10.8.0.2
  • bigbox не имеет публичного IP — весь внешний трафик идёт через vps02.
  • Caddy сам выпускает Let's Encrypt для 443 и проксирует WebSocket/HTTP.
  • 5222/5269 (XMPP-клиенты и федерация) — DNAT в iptables на vps02 → 10.8.0.2.
  • 5281 (Prosody HTTPS: HTTP Upload) — DNAT НЕ проброшен наружу; Caddy на 443 (upload.nixg.ru) → 10.8.0.2:5281.
  • Порт 5280 (Prosody HTTP: WebSocket/BOSH) наружу НЕ торчит — веб-клиент ходит через Caddy на 443 (wss://).

3. Что сделано по шагам (с командами)

3.1 DNS (Jino, панель домена nixg.ru)

Запись Тип Значение
nixg.ru A 81.177.135.175 (визитка Jino — НЕ трогаем)
chat.nixg.ru A 87.242.100.206 (веб-клиент Converse.js)
xmpp.nixg.ru A 87.242.100.206 (WebSocket и всё XMPP)
upload.nixg.ru A 87.242.100.206 (HTTP Upload, XEP-0363)
_xmpp-client._tcp.nixg.ru SRV 0 5 5222 xmpp.nixg.ru.
_xmpp-server._tcp.nixg.ru SRV 0 5 5269 xmpp.nixg.ru.
_xmpp-client-websocket._tcp.nixg.ru SRV 0 5 443 xmpp.nixg.ru.

Проверка: dig +short @1.1.1.1 _xmpp-client._tcp.nixg.ru SRV, dig +short @1.1.1.1 upload.nixg.ru A.

Засада Jino: панель склеивает поля в одну строку (_xmpp-client._tcp.nixg.ruIN SRV0 5 5222 chat.nixg.ru). Решение: заполнять поля формы по отдельности: имя=_xmpp-client._tcp, приоритет=0, вес=5, порт=5222, цель=xmpp.nixg.ru. (FQDN с точкой). Результат в DNS всё равно корректный.

SRV-записи критичны для федерации: без них другие XMPP-серверы не найдут нас.

Засада с DNS-кэшем: после смены A-записи старый IP (визитка 81.177.135.175) висит в кэшах до TTL (10800 сек). Авторитативные NS отдают сразу правильное: dig @ns1.jino.ru upload.nixg.ru A. На bigbox кэш systemd-resolved сбрасывается ТОЛЬКО полным рестартом: sudo systemctl restart systemd-resolved (resolvectl flush-caches НЕ помогает!).

3.2 Prosody на bigbox (/opt/icq)

  • docker-compose.yml — сервисы prosody (prosody/prosody:latest) и webchat (nginx:alpine); см. файл.
  • config/prosody.cfg.lua:
    • VirtualHost nixg.ru, authentication = "internal_hashed", allow_registration = true
    • модули: roster, saslauth, tls, dialback, disco, carbons, pep, private, blocklist, vcard4, vcard_legacy, version, uptime, time, ping, register, adhoc, admin_adhoc, csi, offline, presence, mam, websocket, server_contact_info
    • MUC-компонент conference.nixg.ru (групповые чаты, max_history 200)
    • cross_domain_websocket = { "https://chat.nixg.ru", "https://xmpp.nixg.ru" }
    • HTTP: http_ports = { 5280 }, HTTPS: https_ports = { 5281 } + глобальный ssl (LE-серт)
    • Component upload.nixg.ru (http_upload) — см. раздел 7
    • Логи — в файлы (/var/log/prosody, монтируется ./logs)
  • Регистрация админа: docker exec icq-prosody prosodyctl register admin nixg.ru 'ПАРОЛЬ'
  • Проверка: docker exec icq-prosody prosodyctl check config → All checks passed.

Тонкости Prosody 0.11.9 в docker-образе:

  • Нет community-модулей по умолчанию. Установка: Ubuntu-пакет prosody-modules (apt) даёт их в /usr/lib/prosody/modules/ — копируем нужный в ./modules (монтируется в /etc/prosody/modules).
  • Порты слушаются на 0.0.0.0 — трафик снаружи приходит через WG.

3.3 Caddy на vps02

  • Caddy в Docker (host-network), конфиг /opt/caddy/Caddyfile монтируется в контейнер.
  • Блоки (см. актуальный файл): chat.nixg.ru и xmpp.nixg.ru → 10.8.0.2:8081 (nginx веб-чат), upload.nixg.ru → 10.8.0.2:5281 (Prosody HTTPS, HTTP Upload) с tls_insecure_skip_verify (см. 7), плюс netbox/gitea/homepage/vwar/ollama/webui/search/hermes (другие сервисы).
  • Засада с bind-mount: при перезаписи Caddyfile через sudo tee/> файл получает НОВЫЙ inode, а монтированный в контейнер старый inode остаётся — Caddy продолжает читать СТАРУЮ версию. Симптом: «конфиг изменил, а Caddy проксирует на старый адрес». Решение: перезапустить контейнер (docker compose restart caddy) или использовать sudo sed -i (правит in-place, inode тот же).
  • Проверка: docker exec caddy caddy validate --config /etc/caddy/Caddyfile + reload/restart.

3.4 iptables DNAT на vps02 (форвард 5222/5269 на bigbox)

iptables -t nat -A PREROUTING -i enp3s0 -p tcp --dport 5222 -j DNAT --to-destination 10.8.0.2:5222
iptables -t nat -A PREROUTING -i enp3s0 -p tcp --dport 5269 -j DNAT --to 10.8.0.2:5269
iptables -A FORWARD -d 10.8.0.2 -p tcp --dport 5222 -j ACCEPT  (и 5269)
iptables -t nat -A POSTROUTING -o wg0 -j MASQUERADE
  • Обязательно добавить FORWARD ACCEPT (default policy DROP!) и MASQUERADE — иначе пакеты доходят до bigbox, но ответы не возвращаются (connection refused).
  • Сохранение: sudo sh -c "iptables-save > /etc/iptables/rules.v4" (переживает reboot).
  • Группа безопасности у провайдера (Timeweb): открыть TCP 5222, TCP 5269 (и 443 для Caddy).

3.5 Веб-клиент (Converse.js v14.0.0 + nginx)

  • /opt/icq/webchat: index.html, dist/ (полный дистрибутив v14.0.0), nginx.conf. dist скачан из официального релиза converse.js-14.0.0.tgz (GitHub) — это важно: CDN (cdn.conversejs.org) может резаться из РФ.
  • nginx.conf: server 8081, root /usr/share/nginx/html;
    • location /xmpp-websocket { proxy_pass http://prosody:5280/xmpp-websocket; upgrade-заголовки; proxy_read_timeout 3600s; }
    • location /http-bind { proxy_pass http://prosody:5280/http-bind; }
    • location = /.well-known/host-meta и .json — JSON XEP-0156 (см. 6.4)
    • location / { try_files $uri $uri/ /index.html; }
  • Docker-compose: сервис webchat (nginx:alpine), ports 8081:8081, volume ./webchat:/usr/share/nginx/html:ro.

ЗАСАДА: DNS-кэш nginx → Connection refused на /xmpp-websocket (инцидент 2026-08-30). Симптом: chat.nixg.ru открывается, форма логина мелькает, Converse висит на пульсирующем белом кружке (спиннер connecting). В логах icq-webchat каждую минуту: connect() failed (111: Connection refused) ... upstream http://172.27.0.2:5280/xmpp-websocket. Причина: nginx резолвит имя prosody ОДИН раз при старте и держит IP вечно. После пересоздания контейнеров (prosody, slidgram) docker присваивает адреса заново — старый IP (172.27.0.2) переходит к ДРУГОМУ контейнеру (slidgram), prosody уезжает на .4. nginx же продолжает слать на закешированный .2 → refused → 502 → спиннер. Лечение: docker compose restart webchat (пере-резолв). Профилакт — динамический resolver:

nginx
resolver 127.0.11 valid=30s ipv6=off;   # docker embedded DNS
location /xmpp-websocket {
        set $prosody_upstream http://prosody:5280;
        proxy_pass $prosody_upstream/xmpp-websocket;
        ...
}

Без переменной + resolver nginx не пере-резолвит на лету. Патч применён (2026-08-30), в webchat/nginx.conf.

Критическая засада веб-клиента: в Converse.js v14 опция WebSocket называется websocket_url, НЕ bosh_service_url! Если задать bosh_service_url с wss:// — Converse пытается подключиться по BOSH (а у Prosody BOSH нет), падает, UI не рендерится (пустая страница!).

КРИТИЧНО про v14 + ESM: Converse v14 — ES-модуль (import.meta.url, export{c as default}). Подключение обычным <script src="converse.min.js"> даёт Uncaught SyntaxError: Cannot use 'import.meta' outside a module → пустая страница. Решение — index.html:

<script type="module" src="/dist/converse.min.js"></script>
<script type="module">
  import converse from '/dist/converse.min.js';
  converse.initialize({
    websocket_url: 'wss://xmpp.nixg.ru/xmpp-websocket',  // ОБЯЗАТЕЛЬНО websocket_url!
    view_mode: 'fullscreen',
    auto_login: false,
    theme: 'concord',
    i18n: 'ru',
    show_controlbox_by_default: true,
  });
</script>

Обязателен ПОЛНЫЙ dist из релиза (чанки locales/, libomemo.esm.min.js, curve25519_compiled.wasm, sounds, webfonts).

4. TLS / Let's Encrypt для Prosody (c2s/s2s 5222/5269) — dns-01

Проблема: Prosody (5222/5269) отдавал самоподписанный серт → АБСОЛЮТНО не работала федерация (другие серверы не принимали). Нужен настоящий LE-серт с SAN nixg.ru + xmpp.nixg.ru.

Почему dns-01: http-01 невозможен (A nixg.ru → визитка 81.177.135.175, а не наш сервер), TLS-ALPN не подходит для портов 5222/5269. Jino не имеет публичного DNS API → TXT добавляется ВРУЧНУЮ в панели.

Команды (выполнено 2026-08-28):

# 0. Установить certbot (2.9.0, apt). Если apt сломан dpkg — сначала:
sudo dpkg --configure -a
sudo apt-get install --reinstall chromium-browser   # был сломан snap-transitional, exit 127

# 1. Первый запуск — печатает TXT-значения (ДВА вызова или один с двумя -d):
sudo certbot certonly --manual --preferred-challenges dns-01 \
  -d nixg.ru -d xmpp.nixg.ru \
  --email kpa39l@yandex.ru --agree-tos --no-eff-email

# 2. Добавить в Jino: TXT _acme-challenge.nixg.ru = <значение>, _acme-challenge.xmpp.nixg.ru = <значение>.

# 3. Дождаться DNS (проверка: dig @1.1.1.1 TXT _acme-challenge.nixg.ru)
#    и повторить с хуками (чтобы не ждал интерактивно):
sudo certbot certonly --manual --preferred-challenges dns-01 \
  -d nixg.ru -d xmpp.nixg.ru \
  --manual-auth-hook /bin/true --manual-cleanup-hook /bin/true

# 4. Скопировать в /opt/icq/certs/ (ВАЖНО: cp -L, т.к. live/ — симлинки на archive/)
sudo cp -L /etc/letsencrypt/live/nixg.ru/fullchain.pem /opt/icq/certs/nixg.ru.crt
sudo cp -L /etc/letsencrypt/live/nixg.ru/privkey.pem  /opt/icq/certs/nixg.ru.key

# 5. Рестарт Prosody и проверка серта
cd /opt/icq && docker compose restart prosody
openssl x509 -in certs/nixg.ru.crt -noout -subject -issuer -dates -ext subjectAltName

Проверка снаружи: python-скрипт (openssl 3 STARTTLS XMPP не поддерживает!) — см. раздел 8.

ПРОДЛЕНИЕ (важно!): certbot создал таймер автопродления, но при dns-01 он БЕСПОЛЕЗЕН — каждый раз нужно ВРУЧНУЮ добавлять новый TXT в Jino (панель, без API). Процедура:

sudo certbot renew --manual-auth-hook /bin/true --manual-cleanup-hook /bin/true
# ↑ напечатает новый TXT → добавить в Jino → снова запустить (создаст серт)
# затем: sudo cp -L ... в /opt/icq/certs → docker compose restart prosody

Срок: выпущено 2026-08-28, expires 2026-11-26. Напоминание: cron-задача Hermes 2026-10-15 10:00 (job ca2a23a34905).

5. Проверка работоспособности (извне)

nc -vz 87.242.100.206 5222      # xmpp-client: open
nc -vz 87.242.100.206 5269      # xmpp-server: open
curl -sSI https://chat.nixg.ru/                       # 200, HTML (Converse.js)
curl -sS https://upload.nixg.ru/upload                # 200 (HTTP Upload endpoint)
curl -sS https://xmpp.nixg.ru/.well-known/host-meta   # 200 JSON XEP-0156
# WebSocket-рукоятка (как у браузера):
# GET /xmpp-websocket, Upgrade: websocket, Sec-WebSocket-Protocol: xmpp, Origin: https://chat.nixg.ru
# → 101 Switching Protocols

Засада с hairpin NAT: с самого bigbox/vps02 нельзя проверить свой публичный IP — маршрут <local>, пакет не проходит через DNAT. Нужен внешний хост (WSL, телефон по мобильной сети).

6. Ошибки, на которых мы обожглись (выжимка)

  1. SRV на Jino склеивается — заполнять поля отдельно, цель с точкой.
  2. FORWARD policy DROP + нет MASQUERADE → connection refused на 5222/5269.
  3. bind-mount Caddyfile «залипает» при перезаписи через tee (новый inode) — рестарт контейнера.
  4. bosh_service_url вместо websocket_url → пустая страница Converse.js.
  5. WebSocket-тест без Origin → 403, без Sec-WebSocket-Protocol: xmpp → 501.
  6. hairpin NAT — нельзя тестировать свой pub IP с самого сервера.
  7. Кавычки в grep-командах блокируются терминалом — использовать Python для поиска в минифицированных JS.
  8. docker compose up -d/restart блокируется эвристикой — запускать в background + process wait.
  9. Converse v14 = ESM: без type="module" и полного /dist — import.meta SyntaxError, пустая страница.
  10. DNS-кэш systemd-resolved: после смены A-записи resolvectl flush-caches не помогает — только sudo systemctl restart systemd-resolved.
  11. openssl 3 не умеет -starttls xmpp (XMPP-STARTTLS) — проверять TLS на 5222/5269 только python-скриптом.
  12. dpkg прерван / apt exit 127 — сломан chromium-browser (snap-transitional); чинить dpkg --configure -a + переустановка.
  13. git: .venv попал в историю — venv НЕ коммитить (в .gitignore), удалять из истории filter-branch + force-push (репо похудел с 22MB до 216K).
  14. Prosody 13.0: WS-сессия «insecure» → «no-auth-mech» (2026-08-30) — после миграции на 13.0 веб-чат сломался: форма мелькает, белый кружок, No stream features to offer on insecure session в логах, бесконечные переподключения. TLS терминируется на edge (Caddy→nginx→Prosody plain 5280) → 13.0 не предлагает SASL на «insecure» WS. ФИКС (глобальная секция prosody.cfg.lua): consider_websocket_secure = true. НЕ ставить c2s_require_encryption=false (дыра в 5222: plaintext-пароли). Проверка: Authenticated as user@nixg.ru [prosody:operator] в логах после входа по WS.

7. HTTP Upload (XEP-0363) — mod_http_upload

Зачем: отправка файлов (фото, книги бота) в чаты. Converse умеет из коробки через disco-фичу urn:xmpp:http:upload:0.

Модуль: в docker-образе Prosody 0.11.9 модуля НЕТ (и GitHub/hg/CDN с bigbox недоступны). Установка — Ubuntu-пакет:

sudo apt-get install -y prosody-modules
cp -r /usr/lib/prosody/modules/mod_http_upload /opt/icq/modules/

(./modules монтируется в контейнер как /etc/prosody/modules.)

Конфиг (в prosody.cfg.lua, как Component, НЕ в modules_enabled!):

-- HTTPS-порт для mod_http_upload (требует TLS)
https_ports = { 5281 }
https_interfaces = { "0.0.0.0" }
ssl = { key = "/etc/prosody/certs/nixg.ru.key"; certificate = "/etc/prosody/certs/nixg.ru.crt" }

Component "upload.nixg.ru" "http_upload"
    http_upload_file_size_limit = 10 * 1024 * 1024   -- 10 MB
    http_upload_expire_after = 7 * 24 * 60 * 60      -- 7 дней
    http_upload_require_authentication = true
    http_upload_path = "/var/lib/prosody/http_upload"
    http_external_url = "https://upload.nixg.ru"     -- внешний URL (без :5281!)

Засады:

  • Модуль требует TLS: «File upload MUST happen with TLS but it isn't enabled» → включить https_ports={5281} + глобальный ssl с LE-сертом.
  • Лимит 100 MB → Prosody режет до 10485760 B (потолок HTTP-парсера) — ставить ≤10 MB.
  • http_external_url обязателен, иначе клиент получит URL с :5281 (недоступный снаружи).

Caddy (vps02):

upload.nixg.ru {
    reverse_proxy 10.8.0.2:5281 {
        header_up Host {host}
        transport http {
            tls_insecure_skip_verify    # серт backend'а — для nixg.ru, не upload.nixg.ru!
        }
    }
}

Без skip_verify Caddy рвёт соединение (502): backend-серт prosody — SAN nixg.ru+xmpp.nixg.ru, не upload.nixg.ru. Внутри WG-туннеля это безопасно.

DNS: A upload.nixg.ru → 87.242.100.206 (Jino).

Проверка (slixmpp 1.17 + aiohttp в .venv):

# disco: фичи urn:xmpp:http:upload (+:0) на upload.nixg.ru
url = await bot.plugin["xep_0363"].upload_file(
    filename="test.txt", size=len(payload), content_type="text/plain",
    input_file=io.BytesIO(payload), timeout=25)
# → https://upload.nixg.ru/upload/<token>/test.txt ; GET → 200, содержимое цело

Файлы хранятся: /opt/icq/data/http_upload/<token>/ (в контейнере /var/lib/prosody/http_upload).

8. Проверка TLS (c2s/s2s) python-скриптом

openssl 3 НЕ поддерживает -starttls xmpp (даёт ложную тревогу «Cipher is NONE / no peer cert»). Правильная проверка — вручную:

import socket, ssl
for port in (5222, 5269):
    s = socket.create_connection(("87.242.100.206", port), 10)
    s.send(b"<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' to='nixg.ru' version='1.0'>")
    print(port, "->", s.recv(4096)[:200])   # features: <starttls .../>
    s.send(b"<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>")
    print(port, "->", s.recv(4096)[:100])   # <proceed/>
    ctx = ssl.create_default_context()
    tls = ctx.wrap_socket(s, server_hostname="nixg.ru")
    print(port, "TLS:", tls.version(), tls.getpeercert()["issuer"])
    tls.close()
# Ожидание: TLSv1.3, issuer Let's Encrypt

9. OMEMO (XEP-0384) — сквозное шифрование ✅

Статус: ВЫПОЛНЕНО и проверено end-to-end (2026-08-29).

9.1 Главный вывод: серверный модуль НЕ нужен

OMEMO (XEP-0384) — это полностью клиентское шифрование (Signal-протокол в XMPP):

  • каждый клиент генерирует свою пару (identity key, signed prekey, 100+ prekeys);
  • ключи публикуются в PEP аккаунта пользователя (Personal Eventing, XEP-0163);
  • сервер только хранит эти узлы и пересылает зашифрованные сообщения;
  • расшифровка возможна ТОЛЬКО на клиенте (forward secrecy: ключи сессии после обмена не восстанавливаются).

Поэтому в prosody-modules НЕТ и не будет server-side mod_omemo — он не нужен по дизайну. Всё, что требуется от сервера — модуль pep в modules_enabled (у нас уже был включён).

Ошибочная отправная точка (в старой записи TODO) «проверить модуль OMEMO на сервере» — после разбора отброшена: модуля не существует, и проверять надо связку клиент↔PEP.

9.2 Что подтверждено (доказательства)

Слой Проверка Результат
Сервер (PEP) publish/retract в узел eu.siacs.conversations.axolotl.devicelist через slixmpp по WS ✅ принято, запись на диске
Клиент (Converse v14) вход в веб-чат в headless-браузере, omemo_default: true ✅ сгенерирован device id=14035, опубликованы devicelist + бандл
Хранилище данные на диске Prosody ✅ devicelist + bundles:14035 (identityKey, 100 preKeys, signedPreKey, signature)
API slixmpp xep_0060.publish(jid, node, id='current', payload=<list>) ✅ работает; retract-эквивалент — ре-публикация списка без устройства

9.3 Проверка серверной части (slixmpp 1.17 + WebSocket)

Скрипт в проекте: /opt/icq/scripts/omemo_check.py (сохранён 2026-08-29, работает из .venv).

Ключевые моменты API slixmpp 1.17 (в отличие от старых туториалов):

  • connect((WS_URL,)) возвращает Future и сам управляет циклом; process() больше НЕТ;
  • плагины регистрировать явно: self.register_plugin('xep_0060') (в __init__);
  • publish ждёт payload как XML-элемент (lxml), а не строку:
from slixmpp.xmlstream import ET
lst = ET.Element('{eu.siacs.conversations.axolotl}list')
ET.SubElement(lst, '{eu.siacs.conversations.axolotl}device', {'id': '7777'})
await self['xep_0060'].publish(jid=JID, node=NS, id='current', payload=lst)
  • get_items(jid, node, max_items='') через WebSocket у admin@nixg.ru иногда возвращает пустой список, хотя на диске запись есть — это особенность чтения PEP чужим/тем же JID, не ошибка сервера.

Подключение (SSL не проверяется — серт LE, но для надёжности отключаем проверку):

ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
bot.ssl_context = ctx
fut = bot.connect(('wss://xmpp.nixg.ru/xmpp-websocket',))
await asyncio.wait_for(fut, timeout=25)

9.4 Проверка клиентской части (Converse v14, headless)

  1. Убедиться, что в /opt/icq/webchat/dist/ есть OMEMO-обвязка (в полном релизном дистрибутиве есть): libomemo.esm.min.js, curve25519_compiled.wasm; в converse.min.js есть ключи omemo_default, omemo_active, omemo_store.
  2. В index.html добавлено omemo_default: true (см. 9.6).
  3. Вход в веб-чат: camofox-browser open https://chat.nixg.ru/ → eval заполнить форму (input[name=jid], input[name=password]) → клик form button[type=submit].
  4. Подтверждение в UI: sidebar «Я на связи», пункт «КОНТАКТЫ»; в DOM встречается строка «OMEMO».
  5. Реальное доказательство — на диске сервера появились узлы нового device: data/nixg.ru/pep_eu%2esiacs%2econversations%2eaxolotl%2edevicelist/admin.list и data/nixg.ru/pep_eu%2esiacs%2econversations%2eaxolotl%2ebundles%3a14035/admin.list.

9.5 Что лежит в хранилище Prosody (как читать узлы PEP)

/opt/icq/data/nixg%2eru/pep_eu%2esiacs%2econversations%2eaxolotl%2edevicelist/admin.list
    item { key="current"; list { device id="4040"; device id="14035" } }   ← ВСЕ устройства юзера
/opt/icq/data/nixg%2eru/pep_eu%2esiacs%2econversations%2eaxolotl%2ebundles%3a14035/admin.list
    item { bundle { identityKey; signedPreKeyPublic + signedPreKeySignature;
                    prekeys (100 × preKeyPublic + preKeyId) } }            ← бандл одного device

Назначение узлов:

  • eu.siacs.conversations.axolotl.devicelist — список device-id аккаунта (публикуется при каждом входе);
  • eu.siacs.conversations.axolotl.bundles:<id> — ключи конкретного устройства;
  • urn:xmpp:omemo:2:devices — устройства для OMEMO 2.0-клиентов (новый стандарт).

Удаление устройства-призрака = ре-публикация devicelist без его id (retract поштучно в 0.11 работает плохо — проще переписать весь список).

9.6 Изменение index.html (Converse initialize)

converse.initialize({
    websocket_url: 'wss://xmpp.nixg.ru/xmpp-websocket',
    ...
    omemo_default: true,   // шифровать по умолчанию, когда контакт поддерживает OMEMO
});

Единственная конфиг-опция OMEMO в v14 (проверено по документации conversejs.org/docs/configuration/): omemo_default (default false). Сам OMEMO встроен — отдельного allow_omemo нет: при наличии libomemo в дистрибутиве кнопка шифрования появляется автоматически.

9.7 Засады OMEMO (зафиксировано на практике)

  1. Старый JID в localStorage: если браузер помнит admin@chat.nixg.ru — Prosody отвечает host-unknown, Converse не логинится. Лечение: localStorage.removeItem("conversejs-session-jid") или вход в инкогнито. На новом JID admin@nixg.ru работает.
  2. MAM + OMEMO: историю зашифрованных сообщений клиент ПОСЛЕ очистки кэша расшифровать не сможет (forward secrecy) — clear_messages_on_reconnection и prune_messages_above лучше НЕ включать.
  3. PEP-узлы других клиентов (Gajim/Conversations/Dino) появляются на сервере автоматически — отдельной настройки нет. Devicelist пополняется при каждом логине нового устройства.
  4. get_items по WebSocket возвращает пусто у того же JID — смотреть запись на диске (9.5).

10. Дополнительные модули Prosody (2026-08-29)

Установлены четыре модуля, которых не было в базовом контейнере.

10.1 Источники модулей

# 1) из Ubuntu-пакета prosody-modules (apt) — проверенные community-модули для 0.11:
sudo apt-get install -y prosody-modules            # кладёт в /usr/lib/prosody/modules/
cp -r /usr/lib/prosody/modules/mod_vcard_muc        /opt/icq/modules/
cp -r /usr/lib/prosody/modules/mod_muc_moderation   /opt/icq/modules/
cp -r /usr/lib/prosody/modules/mod_http_upload_external /opt/icq/modules/

# 2) mod_admin_web — сторонний (не в prosody-modules!), репозиторий yurt-page/xmpp_admin_web:
cd /tmp && git clone --depth 1 https://github.com/yurt-page/xmpp_admin_web.git
cp /tmp/xmpp_admin_web/mod_admin_web.lua /opt/icq/modules/
mkdir -p /opt/icq/modules/mod_admin_web && cp -r /tmp/xmpp_admin_web/www_files /opt/icq/modules/mod_admin_web/

(./modules монтируется в контейнер как /etc/prosody/modules; www_files должен лежать РЯДОМ с mod_admin_web.lua — модуль раздаёт их через module:get_directory()/www_files.)

10.2 Что делает каждый модуль

Модуль Функция Как включён
mod_vcard_muc vCard (аватар, описание) комнат MUC, XEP-0153 в MUC в компоненте conference.nixg.ru
mod_muc_moderation Модерация MUC (XEP-0425): бан/кик/смена темы через ad-hoc в компоненте conference.nixg.ru
mod_http_upload_external HTTP Upload с ВНЕШНИМ хранилищем (XEP-0363, отдельный сервис) НЕ включён (см. ниже)
mod_admin_web Ad-hoc админ-команды по XMPP (adminsub, список сессий) — НЕ веб-панель global, modules_enabled
-- в prosody.cfg.lua
modules_enabled = { ..., "admin_web", "bosh", ... }   -- admin_web требует bosh

Component "conference.nixg.ru" "muc"
    ...
    modules = { "vcard_muc", "muc_moderation" }

10.3 Замечания и предупреждения

  • mod_admin_web — модуль 2010 года (Florian Zeitz, MIT). Использует устаревшие API (module.add_host, service[host]:add_subscription, module:set_global(), prosody.hosts). Синтаксис под 0.11 валиден (luac5.1 -p проходит). ✅ Проверено рестартом 2026-08-29: сервер стартовал без error, модуль загрузился, BOSH поднялся — падения нет. ⚠️ УТОЧНЕНИЕ: это XMPP-модуль ad-hoc админ-команд (adminsub, список сессий через subscription-уведомления), а НЕ HTTP-веб-панель. HTTP /admin на :5280 даёт 404. Настоящей веб-админки из коробки в Prosody 0.11 нет (ставят отдельные UI-проекты). Поэтому не переживаем нас сёрфа на /admin и внешних прокси-блоков к нему.
  • mod_http_upload_external НЕ включён: это альтернатива работающему mod_http_upload (классический, с локальным хранилищем, см. раздел 7). External требует отдельного HTTP-сервиса для файлов (например, minio/nginx с подписанными URL). Включить при необходимости: Component "upload-ext.nixg.ru" "http_upload_external" + http_upload_external_base_url.
  • Порядок включения: после правок конфига — docker exec icq-prosody prosodyctl check config → docker compose restart prosody → docker logs icq-prosody --tail 100.

10.4 Push-уведомления (APNs/FCM) — варианты реализации

Статус (2026-08-29): ОТЛОЖЕНО. Особой потребности в push нет; реализуем когда понадобится или когда исчерпается список задач. Ниже — честная картина с учётом нашей версии Prosody.

Как устроен push в XMPP (коротко):

  • XEP-0357 — стандарт push-уведомлений. «Перевёрнутая» схема: не сервер шлёт в APNs/FCM, а мобильный клиент регистрирует у Prosody «push-сервис и токен», и сервер при новом offline-сообщении зовёт push-сервис, а тот уже доставляет через APNs/FCM.
  • Нативные клиенты Conversations (Android) и Monal (iOS) ходят на свои публичные push-шлюзы (conversations.im, push.monal.im) — для них достаточно включить XEP-0357-модули на сервере, свой APNs/FCM-мост поднимать НЕ нужно.

Варианты:

(1) XEP-0357 (рекомендуемый, работает на 0.11) — mod_push + mod_push_offline (+ mod_push_muc для комнат):

# Модули НЕ в Debian-пакете prosody-modules — берутся из официального репозитория prosody-modules (Mercurial):
# https://hg.prosody.im/prosody-modules/  (каталоги mod_push, mod_push_offline, mod_push_muc)
# Положить в /opt/icq/modules/, затем в prosody.cfg.lua добавить в modules_enabled:
#   "push"; "push_offline";   # и "push_muc" для MUC

Плюс: нативный push для Conversations/Monal через их бесплатные публичные шлюзы, без своей инфраструктуры.

(2) UnifiedPush (mod_unified_push) — только после апгрейда Prosody до 0.12:

# mod_unified_push уже есть в apt-prosody-modules (скопирован был из /usr/lib/prosody/modules/mod_unified_push/)
# НО требует util.jwt — это API Prosody 0.12+, в нашем контейнере (0.11.9) его НЕТ → модуль не грузится:
#   modulemanager error: module 'util.jwt' not found
# Поэтому включить его смысл есть только вместе с переходом prosody на 0.12.

Вывод: пока остаёмся на 0.11.9 без push. Когда появится мобильный клиент или потребность — идти по варианту (1): скачать 3 модуля из hg, положить в modules/, включить в конфиг (это под 0.11).

11. Полезные команды

cd /opt/icq
docker compose ps
docker logs icq-prosody --tail 50          # для логов: файлы ./logs/prosody.{log,err}
docker exec icq-prosody prosodyctl check config
docker exec icq-prosody prosodyctl register admin nixg.ru 'ПАРОЛЬ'
docker restart icq-prosody
# внешняя проверка
python3 -c "import socket,ssl; ..."        # см. раздел 8
curl -sS https://upload.nixg.ru/upload

12. Slidgram: регистрация telegram-аккаунта (пошагово)

Мост уже подключён к Prosody (внешний компонент telegram.nixg.ru, трафик TG → SOCKS5 172.27.0.1:1080 → VPS01). Осталось привязать аккаунт. Вся механика — из кода slidgram (в контейнере icq-slidgram: /venv/.../slidgram/gateway.py) и офлайн-доков docs/slidgram/.

12.1 Что понадобится

  • api_id и api_hash — свои, с https://my.telegram.org/apps (завести приложение Telegram). ⚠️ HTTP my.telegram.org из РФ открывается в обычном браузере на вашей машине (туннель для этого не нужен).
  • XMPP-клиент под admin@nixg.ru: веб-чат https://chat.nixg.ru (Converse) или Gajim. Converse поддерживает ad-hoc команды; либо просто отправить сообщение register — оба способа ниже.

12.2 Способ A: ad-hoc команда «Register» (Converse/Gajim)

  1. Открыть https://chat.nixg.ru, войти как admin@nixg.ru.
  2. Начать чат с контактом telegram.nixg.ru (JID компонента — без @ и логина).
  3. В Converse: открыть карточку контакта (info) → там пункт «Команды» (adhoc) → выбрать Register. В Gajim: Правка → Команды (или иконка) → Register.
  4. Заполнить форму: phone (в международном формате +7...), api_id, api_hash.
  5. На ваш Telegram придёт SMS/код подтверждения → ввести его в форму (поле «Confirmation code»).
  6. Если у TG-аккаунта включён 2FA-пароль — мост спросит пароль (поле password).

12.3 Способ B: текстовое сообщение «register» (Conversations и др.)

  1. Отправить сообщение register на адрес telegram.nixg.ru.
  2. Мост ответит формой-запросом: ввести номер телефона, api_id, api_hash (и код).
  3. Далее как в способе A: код из SMS + (опционально) 2FA-пароль.

12.4 Что происходит под капотом (для диагностики)

  • Мост сначала Client.connect() — если уже залогинен, сообщает «two factor» / завершает;
  • затем send_code(phone) → возвращает phone_code_hash (80 сек на ввод кода, REGISTRATION_AUTH_CODE_TIMEOUT в slidgram/config.py);
  • sign_in(phone, code_hash, code) — при 2FA ловит SessionPasswordNeeded → запрашивает пароль;
  • после успеха аккаунт привязан: контакты появляются в ростере XMPP как 123456789@telegram.nixg.ru (puppet-JID), сообщения Telegram дублируются в XMPP.
  • Всё это идёт через MTProto поверх нашего SOCKS5 (SLIDGRAM_PROXY) — из РФ работает.

12.5 Проверка после регистрации

docker logs icq-slidgram --since 10m | grep -iE 'register|login|session|avatar|error'
docker exec icq-prosody prosodyctl list 2>/dev/null   # зарегистрированные XMPP-аккаунты
  • В Converse после входа появится список контактов Telegram (появятся с задержкой синхронизации ростера).
  • Переписка: сообщения, пришедшие в Telegram, будут дублироваться в XMPP-диалог с puppet-контактом.

12.6 Известные грабли

  • Код/пароль 2FA — вводить быстро: таймаут ввода кода 60 сек (по умолчанию).
  • api_id/api_hash нельзя «забыть» ввести: без них мост сам запросит их в форме (если не заданы в конфиге API_ID/API_HASH).
  • На одном номере — только один аккаунт на сервер (при повторной регистрации с тем же номером мост ответит «Someone is already using this phone number on this server»).
  • Аватары могут не подтягиваться (URL-загрузка web.telegram.org не ходит через SOCKS5) — это отдельная задача в STATUS.md; на приём сообщений не влияет.

12.7 Засады 2026-08-29 (после регистрации estorozhenko@nixg.ru)

  • mod_privilege (XEP-0356) ломает Prosody 0.11.9: community-модуль из prosody-modules рассчитан на 0.12 и вызывает module:send_iq() (API 0.12), которого в 0.11 нет → traceback attempt to call method 'send_iq' (a nil value) при каждом privileged IQ. Последствия: слайдж висит на IqTimeout при создании MDS-узла / записи bookmarks, после рестартов контакты TG пропадали из ростера (на сервере рostер цел, клиент просто не получал push).
  • Фикс (0.11-совместимый стаб): в /opt/icq/modules/mod_privilege.lua вызов module:send_iq(wrapped_iq, newsession):next(...) заменён на стаб-ACK — модуль отвечает слайджу st.reply(stanza) (ACK) и НЕ пересылает IQ от имени юзера. Prosody перестаёт падать, слайдж не перезапускается в цикле. Цена: bookmarks/MDS через XEP-0356 не работают, слайдж шлёт приглашения в группы вручную. Коммит: 770c9fa.
  • «Миллион запросов на подключение к чату» в Pidgin — следствие двух вещей:
    1. always_invite_when_adding_bookmarks: true в preferences пользователя (slidge шлёт gateway-приглашение на КАЖДУЮ группу при добавлении закладки);
    2. bookmarks не записываются (mod_privilege сломан) → слайдж падает в fallback и шлёт приглашения вручную на каждый чат. Временное решение: смириться до апгрейда на 0.12 (там XEP-0356 нативный); альтернатива — always_invite_when_adding_bookmarks: false в preferences (запись: slidge.core.session — user.preferences, изменение в SQLite user_account).
  • Flood wait от Telegram при первом старте: слайдж с большим аккаунтом (1602 контакта) при старте тянет инфу о каждом чате/контакте → Telegram банит частые запросы (WARNING:slidgram.telegram:Flood in get_chat(...), sleep for N seconds). Не баг: после кэширования паузы исчезают. Проверка БД: docker exec icq-slidgram python3 -c "import sqlite3;c=sqlite3.connect('/var/lib/slidge/slidge.sqlite');print(c.execute('select count(*) from contact').fetchone())".
  • Контакты «пропали» после рестарта сервера — на самом деле рostер цел на сервере (проверка: slixmpp roster get → 1602 telegram-контакта). Клиент (Pidgin) при переподключении должен сам запросить рostер (Disable/Enable аккаунта). Если нет — проблема в клиентском кэше, не в сервере.

12.8 Планируется: апгрейд Prosody 0.11.9 → 0.12.x (система сборки)

  • Зачем: 0.12 имеет нативный XEP-0356 (mod_privilege больше не нужен), util.jwt (mod_unified_push → push-уведомления), исправления безопасности.
  • Проблема: официальный Docker-образ prosody/prosody не обновлён до 0.12 (latest = 0.11.9; теги только 0.9–0.11 + trunk). Стабильный 0.12.6 доступен только исходниками: https://prosody.im/downloads/source/prosody-0.12.6.tar.gz
  • Сторонние образы: prosodyim/prosody:0.12 (nightly 236 от 2026-04-29) и prosodyim/prosody:13.0 (nightly 98 от 2026-06-07) — НЕ для продакшена.
  • Решение: собрать свой образ из исходников 0.12.6 (Dockerfile на базе prosody/prosody:0.11.9 + tar.gz 0.12.6). См. STATUS.md «Сборка Prosody 0.12».

12.9 Каналы и папки Telegram в рostере (2026-08-29 вечер)

Вопрос пользователя: «как найти каналы из Телеграм в рostере? а ещё папки были в телеге — как вытащить?»

Ответ (проверено) — каналов в рostере XMPP НЕТ, и это нормально:

  • XMPP-рostер (jabber:iq:roster) — это только личные контакты: 1602 записи, все вида число@telegram.nixg.ru (пример: 1646170373@telegram.nixg.ru = «Наталья Рыбий»). Контактов с префиксом group- в рostере — 0.
  • Каналы/группы живут отдельно, в БД моста (таблица room в slidge.sqlite): 91 канал (muc_type='CHANNEL', jid_localpart group-<id>) + 40 групп/прочих.
  • В XMPP каналы должны показываться как закладки комнат (XEP-0402 bookmarks) → в Pidgin «Комнаты → Закладки», откуда заходят. Сейчас bookmarks сломаны из-за mod_privilege-стаба (XEP-0356 не пересылает MDS/pubsub-запросы), поэтому слайдж вместо закладок шлёт приглашения в каждую комнату — «миллион запросов».
  • Обходной путь сейчас: Pidgin → Комнаты → Присоединиться → адрес group-<id>@telegram.nixg.ru, ник estorozhenko (пример group-1001698123474@telegram.nixg.ru). Полный список каналов: SELECT jid_localpart, name FROM room WHERE muc_type='CHANNEL' в БД моста.
  • Папки Telegram: слайдж не поддерживает (session.py:292, обработчик DialogPeerFolder — # TODO: investigate what that is; return). В XMPP аналога «папок чатов» нет (рostер плоский). Pyrogram get_folders() (messages.GetDialogFilters) технически умеет читать папки, но отдельный MTProto-клиент на той же сессии выкинет работающий мост → вариант только «остановить мост на 30 сек, прочитать, запустить». Практической ценности нет — в XMPP-клиенте папки всё равно некуда вывести.

Диагностика рostера (полезный навык, не повторять ошибку):

  • Признак «контакты пропали»: клиент (slixmpp/Pidgin) показывает пусто. Проверять НЕ через bot.roster/roster_update (slixmpp читает свой кэш и событие срабатывает раньше полного ответа — ложно показывает 1 контакт), а сырым IQ: bot.make_iq_get(queryxmlns='jabber:iq:roster').send() → .findall('.//{jabber:iq:roster}item'). Верифицировано: вернул 1602 item'а.
  • Рostер-файл: /var/lib/prosody/nixg%2eru/roster/estorozhenko.dat (445 КБ, 1602 записи, версия 1607) — читается через datamanager.load("estorozhenko","nixg.ru","roster") только при установленном data_path (вне Prosody-контекста он nil → ложный ENOENT).
  • Debug-логирование mod_privilege: добавить debug = ".../prosody.log" в блок log конфига (временно; после отладки вернуть) — в логе видно Entity is privileged на обоих хостах, load_roster: asked for / save_roster: saving roster (all contacts).
  • Замечание: каталог данных называется nixg%2eru (URL-кодирование точки), а не nixg.ru — ls /var/lib/prosody/nixg.ru даёт «No such file» — это норма, не ошибка.

Причина «миллиона приглашений» подтверждена: в БД моста user_account.preferences JSON содержит "always_invite_when_adding_bookmarks": true — слайдж шлёт приглашение на каждую группу при синхронизации. Кандидат на false, когда bookmarks заработают (после апгрейда 0.12). Пока слайдж-стаб не чинит bookmarks, приглашения — единственный способ «увидеть» каналы, их можно игнорировать в Pidgin.

12.8. МИГРАЦИЯ ПРОДА НА PROSODY 13.0 (2026-08-30)

Прод nixg.ru переведён с 0.11.9 на 13.0 (образ gitea.nixg.ru/hermes/icq-prosody:13.0). Стенд подтверждал совместимость заранее (references/prosody-13-parallel-stand.md).

Шаги и изменения конфига (что отличается от 0.11.9):

  1. Бэкап перед миграцией: tar czf backups/pre-migration-13-*.tar.gz config data certs modules webchat.
  2. docker-compose: image: gitea.nixg.ru/hermes/icq-prosody:13.0 (вместо prosody/prosody:latest).
  3. component_ports = { 5347 } — УДАЛЁН в 13.0 (listener жёстко на 5347). Убрать. Listener поднимает module-less Component "telegram.nixg.ru"-блок (уже был).
  4. "pubsub" из modules_enabled VirtualHost — УБРАТЬ: в 13.0 mod_pubsub только как Component. Добавлен Component "pubsub.nixg.ru" "pubsub" (bookmarks/ленты). Ошибка до фикса: Pubsub should be loaded as a component.
  5. РЕГРЕССИЯ http_upload: slidge получал No upload slot in this IQ: <iq ... id="0" /> — в 13.0-версии mod_http_upload слот выдаётся только origin.type=="c2s" ИЛИ JID из http_upload_access. Slidge просит слот от имени компонента → добавить: http_upload_access = { "telegram.nixg.ru" } в блок Component "upload.nixg.ru". До фикса 180 ошибок/10 мин; после — 0.
  6. cross_domain_websocket — deprecated (варнинг), но всё ещё читается mod_websocket → работает. Можно оставить; новый механизм — http_cors_override.
  7. Лог: один блок log в глобальной секции (в 13.0 строго один).

Проверка после миграции:

  • prosodyctl check config → All checks passed.
  • External component successfully authenticated для telegram.nixg.ru в prosody.log.
  • mod_privilege: в логе nixg.ru:privilege ... Archiving stanza (slidge-carbon-*) — работает.
  • WebSocket: рукопожатие → 101 Switching Protocols через nginx (Chat работает).
  • HTTP Upload: 0 ошибок No upload slot после рестарта.
  • Авторизация: SASL отвечает на неверный пароль AUTH FAILED (логика работает).

Тестовый стенд /opt/icq/test (icq-prosody-test) ОСТАНОВЛЕН: docker stop + docker update --restart=no. Поднять при следующем обновлении: cd /opt/icq/test && docker compose up -d.

13. TODO (следующие шаги)

  • OMEMO (XEP-0384) — ВЫПОЛНЕНО: раздел 9; скрипт scripts/omemo_check.py.
  • Бот-книгоискатель (slixmpp + OPDS) — Этап 3 PRD. venv уже создан (/opt/icq/.venv: aiohttp, slixmpp).
  • Мосты mautrix-whatsapp — Этап 4 (нужен Prosody mod_component / external component). [Telegram уже на Slidge]
  • Push-уведомления APNs/FCM — ОТЛОЖЕНО, варианты в разделе 10.4.
  • Продление LE-серта nixg.ru — 2026-10-15 10:00 (cron Hermes, job ca2a23a34905); процедура в разделе 4.