Files
icq/WALKTHROUGH.md
T

659 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Развёртывание 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](STATUS.md) — текущее состояние и TODO · [PRD.md](PRD.md) — требования/архитектура · [MIGRATION.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`.
**Критическая засада веб-клиента**: в 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:
```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)**:
```bash
# 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). Процедура:
```bash
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. Проверка работоспособности (извне)
```bash
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).
## 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-пакет:
```bash
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!):
```lua
-- 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):
```python
# 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»).
Правильная проверка — вручную:
```python
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), а не строку:
```python
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, но для надёжности отключаем проверку):
```python
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)
```js
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 Источники модулей
```bash
# 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` |
```lua
-- в 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 для комнат):
```bash
# Модули НЕ в 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:
```bash
# 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. Полезные команды
```bash
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 Проверка после регистрации
```bash
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.
## 13. TODO (следующие шаги)
- [x] ~~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.