first_commit

This commit is contained in:
2026-08-28 19:36:10 +00:00
commit a104b6d764
11 changed files with 1334 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Секреты и данные (НЕ в репозиторий)
certs/
data/
logs/
backups/
# Пароли и ключи в доке — если вдруг появятся
*.key
*.pem
*.crt
# Локальное
.docker/
*.log
# Веб-клиент: полный dist Converse (24MB, скачивается из релиза)
webchat/dist/
# Легаси-бандлы в корне webchat (index.html использует /dist/*)
webchat/converse.min.js
webchat/converse.min.css
+89
View File
@@ -0,0 +1,89 @@
# Миграция XMPP: chat.nixg.ru → nixg.ru (юзеры user@nixg.ru, XMPP-домен через xmpp.nixg.ru)
> Дата: 2026-08-28 (вечер) · bigbox (/opt/icq)
> Статус: ВЫПОЛНЕНО (кроме SRV-DNS у пользователя + замена самоподписанного cert)
> Цель: JID юзеров `user@nixg.ru`, Prosody на VirtualHost nixg.ru, веб-клиент остаётся на https://chat.nixg.ru (страница),
> WebSocket клиентов → **wss://xmpp.nixg.ru/xmpp-websocket** (поддомен, визитка на apex НЕ тронута).
## ВЫБРАННЫЙ ВАРИАНТ: xmpp.nixg.ru (а не apex!)
- Пользователь согласился на вариант **xmpp.nixg.ru**: визитка на apex nixg.ru (81.177.135.175) осталась нетронутой.
- XMPP-сервер: VirtualHost **nixg.ru** (JID: user@nixg.ru).
- WS-адрес: **wss://xmpp.nixg.ru/xmpp-websocket** (A-запись xmpp.nixg.ru → 87.242.100.206, LE-серт через Caddy).
- Веб-клиент: **https://chat.nixg.ru** (страница, Converse.js), websocket_url → wss://xmpp.nixg.ru/xmpp-websocket.
## Что сделано (все шаги выполнены и проверены)
### 1. Бэкап
- /opt/icq/backups/pre-migration-20260828-184316.tar.gz (data, config, certs, docker-compose.yml, webchat/)
- /opt/icq/backups/data-pre-migration-clean-*.tar.gz
### 2. DNS (панель Jino) — ЧАСТИЧНО, ОСТАЛОСЬ У ПОЛЬЗОВАТЕЛЯ
- ✅ A `xmpp.nixg.ru` → 87.242.100.206 (добавлен, проверено через dig @8.8.8.8)
- ⏳ SRV _xmpp-client._tcp.nixg.ru → **0 5 5222 xmpp.nixg.ru.** (сейчас ещё chat.nixg.ru)
- ⏳ SRV _xmpp-server._tcp.nixg.ru → **0 5 5269 xmpp.nixg.ru.** (сейчас ещё chat.nixg.ru)
- ⏳ SRV _xmpp-client-websocket._tcp.nixg.ru → **0 5 443 xmpp.nixg.ru.** (сейчас ещё chat.nixg.ru — заметил пользователь!)
- НЕ ТРОГАТЬ: A nixg.ru (визитка 81.177.135.175), A chat.nixg.ru (веб), MX mail.nixg.ru (почта)
### 3. Сертификаты
- ✅ Caddy на vps02 выпустил **настоящий Let's Encrypt** для **xmpp.nixg.ru** (acme-v02, issuer YE2, 2026-08-28, до 2026-11-26) — WSS работает.
- ✅ Самоподписанный `nixg.ru.crt/key` сгенерирован (SAN: nixg.ru, xmpp.nixg.ru, localhost) → /opt/icq/certs/
- ⏳ **TODO**: заменить самоподписанный cert Prosody (c2s/s2s 5222/5269) для nixg.ru на настоящий LE (dns-01) — для федерации.
### 4. Prosody (config/prosody.cfg.lua) — ✅
- VirtualHost: **nixg.ru** (вместо chat.nixg.ru)
- admins = { "admin@nixg.ru" }
- ssl: nixg.ru.crt/key
- cross_domain_websocket = { "https://chat.nixg.ru", "https://xmpp.nixg.ru" }
- MUC: Component "conference.nixg.ru" "muc"
- Аккаунт: **admin@nixg.ru**, SCRAM-хеш скопирован из data/chat%2enixg%2eru/accounts/admin.dat → data/nixg%2eru/accounts/admin.dat
→ пароль тот же: `<в защищённом хранилище>`; проверено: вход по wss://xmpp.nixg.ru AUTH OK (slixmpp)
- Старые данные chat%2enixg%2eru оставлены (для отката); PEP/OMEMO админа НЕ переносились (ключи пересоздаст клиент)
### 5. Caddy (vps02, /opt/caddy/Caddyfile) — ✅
- Добавлен блок:
```
xmpp.nixg.ru {
# XMPP WebSocket (Converse.js → nginx на bigbox, TLS выпустит Caddy)
reverse_proxy 10.8.0.2:8081 {
header_up Host {host}
header_up X-Forwarded-Proto https
}
}
```
- Бэкап Caddyfile: /opt/caddy/Caddyfile.bak-*
- Проверка: wss://xmpp.nixg.ru/xmpp-websocket → **101 Switching Protocols** (curl --http1.1, Sec-WebSocket-Protocol: xmpp)
### 6. nginx/webchat (bigbox) — ✅
- webchat/index.html: websocket_url → **'wss://xmpp.nixg.ru/xmpp-websocket'**
- webchat/nginx.conf: server_name chat.nixg.ru xmpp.nixg.ru
### 7. Проверка end-to-end — ✅
- https://xmpp.nixg.ru/ → 200 (Caddy+nginx)
- TLS: LE (issuer YE2), valid до 2026-11-26
- WS: wss://xmpp.nixg.ru/xmpp-websocket → 101
- Вход admin@nixg.ru через wss://xmpp.nixg.ru: AUTH OK (slixmpp)
- Веб-чат: https://chat.nixg.ru открылся у пользователя (Converse.js работает)
- ⚠️ ЗАЛИПШИЙ localStorage: в браузере мог остаться старый JID admin@chat.nixg.ru → ошибка host-unknown; просто удалить localStorage/зайти в инкогнито
## Что осталось
1. **DNS (пользователь, панель Jino)**: 3 SRV-записи → xmpp.nixg.ru. (см. п.2)
2. **Замена самоподписанного cert Prosody** на LE (dns-01) — для федерации (сейчас с s2s могут быть проблемы с другими серверами)
3. **Документация**: WALKTHROUGH.md и PRD.md ещё содержат старые адреса chat.nixg.ru — обновить
## Как проверить (команды)
```bash
cd /opt/icq && docker compose ps
dig +short A xmpp.nixg.ru # → 87.242.100.206
dig +short SRV _xmpp-client._tcp.nixg.ru # → 0 5 5222 xmpp.nixg.ru.
echo | openssl s_client -connect xmpp.nixg.ru:443 -servername xmpp.nixg.ru 2>/dev/null | openssl x509 -noout -issuer -dates
curl -sk --http1.1 -v -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Protocol: xmpp" https://xmpp.nixg.ru/xmpp-websocket # → 101
python3 /tmp/wstest_nixg.py # AUTH OK admin@nixg.ru
```
## Файлы
- /opt/icq/config/prosody.cfg.lua
- /opt/icq/docker-compose.yml
- /opt/icq/webchat/index.html, /opt/icq/webchat/nginx.conf
- /tmp/wstest_nixg.py (тест входа)
- Caddy: /opt/caddy/Caddyfile (vps02)
- DNS: панель Jino (nixg.ru)
+180
View File
@@ -0,0 +1,180 @@
# PRD: Семейный мессенджер ICQ (XMPP) на chat.nixg.ru
> Версия: 0.2 · Дата: 2026-08-28 · Статус: в разработке (Этап 1 завершён, Этап 2 начат)
>
> **Практический разбор всего сделанного: [WALKTHROUGH.md](WALKTHROUGH.md)** —
> пошаговое описание с командами, почему выбрана такая архитектура, и все «засады».
>
> **Текущее состояние и план: [STATUS.md](STATUS.md)** — учётные данные, что работает,
> проблема пустой страницы + план исправления (точка входа для новой сессии).
## 1. Обзор (Executive Summary)
Разворачиваем собственный федеративный мессенджер для семьи на базе протокола
**XMPP**. Сервер (Prosody) живёт на **bigbox** в каталоге `/opt/icq`, наружу
выставляется через **vps02** (Caddy + WireGuard). Домен: **chat.nixg.ru**.
Федерализация — обязательное требование: общение с любым XMPP-сервером в мире
(email-подобная модель s2s).
Дополнительно: **бот-книгоискатель**, который по запросу находит и скачивает
книги из сетевых библиотек (OPDS-каталоги) и присылает файл прямо в чат.
## 2. Цели (Goals)
- Личный, принадлежащий нам мессенджер для семьи (родители, дети).
- Федеративность: связь с пользователями любых XMPP-серверов.
- Клиенты под iOS и Android из официальных сторов (Snikket / Monal / Conversations).
- Сквозное шифрование (OMEMO) по умолчанию.
- Работает через наш туннель и не зависит от публичных мессенджеров.
- Бот, автоматизирующий скачивание книг из сетевых библиотек.
## 3. Не-цели (Non-Goals)
- Не пытаемся «слямзить» протокол Telegram/MTProto — это закрытый централизованный
протокол, серверная часть не существует и не появится. Федеративность = XMPP.
- Не строим свой клиент — используем готовые (Element/Snikket/Monal/Conversations).
- Пока не делаем мост в Telegram/WhatsApp (mautrix) — только если понадобится позже.
## 4. Архитектура
### 4.1 Компоненты
```
[Клиенты: iOS/Android (Snikket/Monal/Conversations)]
│ c2s (5222, TLS)
▼
[bigbox] Prosody (XMPP-сервер, Docker, /opt/icq)
│ s2s (5269, TLS) — федерация с другими серверами
│ http_upload (XEP-0363) — передача файлов (книги бота)
▼
[Бот-книгоискатель] slixmpp на bigbox, логинится как bot@chat.nixg.ru
│ OPDS (HTTP)
▼
[Сетевые библиотеки: Флибуста и др.]
```
### 4.2 Сеть (доступность)
- **bigbox** — внутренний сервер (без публичного IP), Docker Compose.
- **vps02** — публичный вход: Caddy (TLS, reverse-proxy WebSocket/HTTP) + WireGuard
туннель до bigbox.
- **Порты:** 443 (клиенты, веб) + 5222 (прямые клиенты) + 5269 (федерация s2s).
- Форвард 5222/5269 с vps02 на bigbox через WireGuard (socat или iptables).
### 4.3 DNS (важно для федерации)
```
A chat.nixg.ru → публичный IP vps02
SRV _xmpp-client._tcp.chat.nixg.ru → chat.nixg.ru:5222
SRV _xmpp-server._tcp.chat.nixg.ru → chat.nixg.ru:5269
```
Без SRV-записей другие XMPP-серверы не смогут нас найти (федерация не заработает).
Входящая федерация требует открытого 5269 на vps02.
### 4.4 TLS / сертификаты
- 443: Caddy выпускает сам (Let's Encrypt, http-01).
- 5222/5269: dns-01 (домен у нас, DNS контролируем) → e.g. через Caddy/acme.sh.
- Сертификат должен покрывать имена `chat.nixg.ru` (+ возможно `conference.chat.nixg.ru` для MUC).
## 5. Стек технологий
| Слой | Технология | Почему |
|------|-----------|--------|
| Протокол | XMPP (RFC 6120/6121) | Федеративный, открытый, XML — как email |
| Сервер | Prosody (Lua, Docker) | Лёгкий, гибкий, модули (http_upload, muc), удобен за реверс-прокси |
| Внешний доступ | Caddy на vps02 + WireGuard | TLS автоматически, reverse-proxy, WG-туннель до bigbox |
| Клиенты iOS | Monal / Snikket | Федерация + OMEMO + push (APNs) |
| Клиенты Android | Conversations / Snikket | Федерация + OMEMO + push (FCM) |
| Бот | Python + slixmpp | Зрелая живая библиотека XMPP |
| Поиск книг | OPDS (Atom/XML каталоги) | Стандарт сетевых библиотек (Флибуста и др.) |
| Передача файлов | XMPP HTTP Upload (XEP-0363) | Отправка книг в чат |
| Развёртывание | Docker Compose в /opt/icq | Изоляция, простота, переносимость |
Примечание: Snikket как *сервер* не берём (спроектирован под прямой 443, капризен
за реверс-прокси), но его *клиенты* хороши — используем их.
## 6. Функциональные требования
### 6.1 Обязательные (Must have)
- FR-1: Регистрация/вход по JID (user@chat.nixg.ru) с паролем.
- FR-2: Личные сообщения 1-на-1, статусы присутствия (presence).
- FR-3: Групповые чаты (MUC, конференции).
- FR-4: Сквозное шифрование OMEMO.
- FR-5: Передача файлов (HTTP Upload) — фото, документы.
- FR-6: Федерация s2s — общение с пользователями других серверов.
- FR-7: Админка/панель управления сервером (Prosody admin + командная строка).
- FR-8: Клиенты iOS и Android из официальных сторов.
### 6.2 Желательные (Should have)
- FR-9: Push-уведомления на iOS/Android (APNs/FCM) — через push-шлюз (Snikket/Conversations могут ходить на свой или публичный).
- FR-10: Бот-книгоискатель (slixmpp + OPDS).
- FR-11: Бэкапы базы/конфигов (в наш общий бэкап /opt/hermes).
- FR-12: Логирование и мониторинг (доступность).
### 6.3 Мосты в Telegram и WhatsApp (Bridges)
- FR-13: Мост в **Telegram** (mautrix-telegram) — общение с контактами Telegram из нашего клиента.
- Двусторонняя переписка: наш пользователь ↔ контакт в Telegram.
- Возможность отправлять/принимать файлы, реакции (по возможностям моста).
- Авторизация через Telegram (логин/QR/MTProto), управление через панель бота моста.
- FR-14: Мост в **WhatsApp** (mautrix-whatsapp) — общение с контактами WhatsApp из нашего клиента.
- Двусторонняя переписка: наш пользователь ↔ контакт в WhatsApp.
- Авторизация через QR-код WhatsApp Web.
- Передача файлов, голосовых (по возможностям моста).
- FR-15: Общий мост-хостинг (mautrix с Postgres/базой), регистрация в Prosody как компоненты.
- OQ-5: Какие мосты деплоим сразу (оба), какие позже? Сначала Telegram, потом WhatsApp (обычно надёжнее).
## 7. Бот-книгоискатель (детально)
### 7.1 Сценарий
1. Пользователь пишет боту `book@chat.nixg.ru`: «Пушкин Капитанская дочка».
2. Бот ищет по OPDS-каталогам (Флибуста и др.).
3. Находит, скачивает (epub/fb2), присылает файлом через HTTP Upload.
4. (Опц.) Ограничение размера, только легальные источники.
### 7.2 Требования
- FR-B1: Приём запросов через личные сообщения.
- FR-B2: Поиск по OPDS (Atom feed).
- FR-B3: Скачивание и отправка файла в чат (XEP-0363).
- FR-B4: Логирование запросов.
- FR-B5: Анти-флуд / ограничения.
## 8. Открытые вопросы (Open Questions)
- OQ-1: Какая библиотека OPDS основная? Флибуста (нужны ли регистрация/логин)?
- OQ-2: ~~Snikket-клиенты работают только со Snikket-серверами?~~ (нет — возьмём Monal + Conversations)
- OQ-3: Нужен ли мост Telegram (mautrix-telegram) сразу или позже?
- OQ-4: ~~Нужен ли веб-клиент (Converse.js) на 443 через Caddy?~~ **✅ СДЕЛАНО 2026-08-28** —
https://chat.nixg.ru, Converse.js v14, локальные ассеты, WebSocket wss://chat.nixg.ru/xmpp-websocket.
## 9. Roadmap (этапы)
1. **Этап 1 — Фундамент (✅ ЗАВЕРШЁН 2026-08-28):** Docker Compose Prosody на bigbox (/opt/icq),
базовые модули, DNS на chat.nixg.ru + SRV (A, _xmpp-client, _xmpp-server — внесены на Jino),
Caddy на vps02 (443, reverse-proxy), форвард 5222/5269 через WG (iptables DNAT + FORWARD + MASQUERADE),
группа безопасности Timeweb (TCP 5222/5269), веб-клиент Converse.js v14 на 443.
Проверено: 5222/5269 открыты извне, webchat отдаётся, WebSocket 101 Switching Protocols.
Подробности: [WALKTHROUGH.md](WALKTHROUGH.md).
2. **Этап 2 — Семья и федерация:** MUC (есть), OMEMO, **HTTP Upload (XEP-0363)** — установить
community-модуль mod_http_upload из prosody-modules (в docker-образе Prosody 0.11.9 его НЕТ),
добавление внешнего контакта (проверка s2s), настоящие сертификаты dns-01 для 5222/5269.
Приглашение семьи. Клиенты iOS/Android.
3. **Этап 3 — Бот:** slixmpp-бот с OPDS-поиском, HTTP Upload.
4. **Этап 4 — Полировка:** push, бэкапы, мониторинг, (опц.) мосты mautrix.
## 10. Принятые решения (Decisions)
- D-1: Протокол = XMPP (не Telegram/MTProto).
- D-2: Сервер = Prosody (не Snikket-сервер, не Ejabberd).
- D-3: Размещение = bigbox (/opt/icq), внешний вход = vps02 (Caddy + WG).
- D-4: Домен = chat.nixg.ru.
- D-5: Бот = Python slixmpp + OPDS + HTTP Upload.
## 11. Риски
- R-1: Флибуста меняет зеркала/требует VPN/логин — бот должен уметь переключать источники.
- R-2: Push на iOS (APNs) сложен для self-hosted — упираемся в публичный push-шлюз.
- R-3: Ограничение 5269 у провайдера vps02 — проверить заранее.
- R-4: Семейные пользователи малознакомы с XMPP-клиентами — подготовить инструкцию.
+50
View File
@@ -0,0 +1,50 @@
# ICQ — приватный XMPP-сервер (Prosody + Converse.js)
Личный XMPP-сервер для семьи/друзей. Домен: **nixg.ru**, веб-чат: **https://chat.nixg.ru**.
## Стек
- **Prosody 0.11.9** (Docker) — XMPP-сервер: c2s 5222, s2s 5269, WebSocket 5280, HTTPS 5281
- **Converse.js v14** (веб-клиент, ESM) — отдаётся nginx (Docker), доступ через Caddy (vps02)
- **Caddy** (vps02, host-network) — TLS-терминатор: chat.nixg.ru, xmpp.nixg.ru, upload.nixg.ru
- **Let's Encrypt** — сертификаты для XMPP (c2s/s2s) и веб
- **WireGuard** — туннель bigbox (10.8.0.2) ↔ vps02 (10.8.0.4)
## Состав (после клонирования)
```
/opt/icq
├── config/ # prosody.cfg.lua (+ mounts в контейнер)
├── webchat/ # nginx: index.html + nginx.conf (dist скачивается отдельно)
├── modules/ # community-модули Prosody (mod_http_upload)
├── docker-compose.yml
├── STATUS.md # точка входа: текущее состояние, задачи
├── PRD.md # требования/архитектура
├── MIGRATION.md # переезд chat.nixg.ru → nixg.ru
└── WALKTHROUGH.md # команды, засады, решения
```
## Быстрый запуск
```bash
# 1. dist Converse.js (в .gitignore, т.к. 24MB) — скачать из релиза:
# https://github.com/conversejs/converse/releases → converse.js-14.0.0.tgz
# распаковать package/dist/* в webchat/dist/
# 2. Сертификаты: положить LE-серт nixg.ru (fullchain, privkey) в certs/nixg.ru.{crt,key}
# 3. Запуск
docker compose up -d
```
## Домены (DNS, Jino)
| Запись | Значение |
|---|---|
| A chat.nixg.ru, xmpp.nixg.ru, upload.nixg.ru | 87.242.100.206 (vps02) |
| SRV _xmpp-client._tcp.nixg.ru | 5222 xmpp.nixg.ru |
| SRV _xmpp-server._tcp.nixg.ru | 5269 xmpp.nixg.ru |
| SRV _xmpp-client-websocket._tcp.nixg.ru | 443 xmpp.nixg.ru |
| TXT _acme-challenge.nixg.ru (при продлении) | временно |
## Лицензия
Приватный проект. Пароли и ключи в репозиторий не попадают (см. .gitignore).
---
Подробности: [STATUS.md](STATUS.md) (текущее состояние), [PRD.md](PRD.md), [WALKTHROUGH.md](WALKTHROUGH.md)
+133
View File
@@ -0,0 +1,133 @@
# ICQ XMPP — ТЕКУЩИЙ СТАТУС И ПЛАН (точка входа для новой сессии)
> Обновлено: 2026-08-28 ≈ 19:00 UTC · Сессия: bigbox (/opt/icq)
> ⚠️ МИГРАЦИЯ НА nixg.ru (2026-08-28): JID юзеров теперь `user@nixg.ru`, веб-клиент остаётся на chat.nixg.ru, WS → wss://xmpp.nixg.ru. См. MIGRATION.md.
> Читать вместе с: [PRD.md](PRD.md) (требования/архитектура), [WALKTHROUGH.md](WALKTHROUGH.md) (опыт, команды, засады), [MIGRATION.md](MIGRATION.md) (переезд).
----------------------------------------------------------------------
## 1. КРИТИЧНЫЕ ДАННЫЕ (для продолжения работы)
### Учётные данные
- **admin@nixg.ru / пароль: `<СМОТРИ В ЗАЩИЩЁННОМ ХРАНИЛИЩЕ / .env>`** ← перенесён 2026-08-28 с admin@chat.nixg.ru, ВХОД ПОДТВЕРЖДЁН
(проверено через WebSocket: slixmpp + wss://xmpp.nixg.ru/xmpp-websocket → AUTH OK).
- Если пароль снова понадобится сбросить:
`docker exec icq-prosody prosodyctl register admin nixg.ru 'НОВЫЙПАРОЛЬ'`
- Старый аккаунт admin@chat.nixg.ru: домен chat.nixg.ru убран из Prosody (VirtualHost теперь nixg.ru).
### Инфраструктура (после миграции)
- bigbox (машина агента), каталог: **/opt/icq**
- Контейнеры: **icq-prosody** (Prosody 0.11.9: 5222/5269/5280/5281), **icq-webchat** (nginx:8081)
- vps02 (87.242.100.206, Debian 12, enp3s0): Caddy (443, host-network), iptables DNAT 5222/5269 → 10.8.0.2
- WireGuard: bigbox 10.8.0.2 ↔ vps02 10.8.0.4; Caddy → 10.8.0.2:8081
- DNS (Jino):
- A `chat.nixg.ru` → 87.242.100.206 (веб-клиент, остаётся)
- A `xmpp.nixg.ru` → 87.242.100.206 (XMPP WS, **добавлен**)
- A `nixg.ru` → 81.177.135.175 (визитка, НЕ трогаем)
- SRV `_xmpp-client._tcp.nixg.ru` → 0 5 5222 **xmpp.nixg.ru.** (было chat.nixg.ru)
- SRV `_xmpp-server._tcp.nixg.ru` → 0 5 5269 **xmpp.nixg.ru.** (было chat.nixg.ru)
- SRV `_xmpp-client-websocket._tcp.nixg.ru` → 0 5 443 **xmpp.nixg.ru.** (было chat.nixg.ru)
- MX `mail.nixg.ru` — НЕ трогаем (почта)
- Prosody: VirtualHost **nixg.ru**, cross_domain_websocket = { "https://chat.nixg.ru", "https://xmpp.nixg.ru" }
- WebSocket: wss://xmpp.nixg.ru/xmpp-websocket — РАБОТАЕТ (101, авторизация OK, LE-сертификат)
### Что уже РАБОТАЕТ (подтверждено)
- ✅ XMPP-сервер на nixg.ru, федерация (SRV), порты 5222/5269 открыты извне
- ✅ TLS на 443 (Caddy/Let's Encrypt): xmpp.nixg.ru (LE, получен 2026-08-28) и chat.nixg.ru (страница)
- ✅ WebSocket-путь целиком: Caddy (xmpp.nixg.ru:443 LE) → nginx:8081 → Prosody:5280 → 101 Switching Protocols
- ✅ Аутентификация по WebSocket: admin@nixg.ru входит через wss://xmpp.nixg.ru (проверено slixmpp, 2026-08-28)
- ✅ **ВЕБ-ЧАТ https://chat.nixg.ru/ РАБОТАЕТ** (v14.0.0, full dist, ru; Converse подключается к
wss://xmpp.nixg.ru, вход admin@nixg.ru подтверждён headless-браузером)
## 2. ТЕКУЩАЯ ПРОБЛЕМА (блокер)
**ПУСТАЯ СТРАНИЦА — РЕШЕНО ✅ (2026-08-28 ≈ 18:30 UTC)**
### КОРЕНЬ ПРИЧИНЫ (диагностировано headless-браузером)
Converse **v14 собирается как ES-модуль** (ESM), а НЕ как классический UMD-бандл:
- бандл использует `import.meta.url` (webpack auto-publicPath) и `export{c as default}`;
- подключение через обычный `<script src="converse.min.js">` даёт в Chrome/Firefox:
`Uncaught SyntaxError: Cannot use 'import.meta' outside a module` → скрипт не выполняется → пустая страница.
- Ранее лежавший в корне `/converse.min.js` (1.3MB, скачан с CDN) — это и был ESM-бандл, но без `type="module"` он молча падал; также отсутствовал `/dist/` с чанками.
### РЕШЕНИЕ (Шаги 2+3 плана, выполнено)
1. Скачан официальный релиз `converse.js-14.0.0.tgz` (GitHub) и full dist разложен в `/opt/icq/webchat/dist/`:
`converse.min.js`, `converse.min.css`, `chunkjs/locales/...` (русская локаль), `libomemo.esm.min.js`, `curve25519_compiled.wasm`, sounds, webfonts, images.
2. `/opt/icq/webchat/index.html` переписан: `<script type="module" src="/dist/converse.min.js">`
+ `import converse from '/dist/converse.min.js'` + `converse.initialize({...})`.
nginx: контейнер монтирует ./webchat:/usr/share/nginx/html — подхватилось без рестарта.
3. Проверено headless-браузером (chromium --headless + Camofox): UI рендерится (лог-ин форма,
русская локаль), вход admin@chat.nixg.ru проходит, XMPP-сессия идёт через wss://chat.nixg.ru/xmpp-websocket.
### Остаточные мелочи (не блокеры)
- ✅ host-meta (XEP-0156) ИСПРАВЛЕН (2026-08-28): nginx webchat отдаёт JSON на /.well-known/host-meta и /.well-known/host-meta.json → wss://xmpp.nixg.ru/xmpp-websocket. Ошибка «XML Parsing Error» в консоли Converse ушла.
- Ошибка MAM `<iq ... max_items: not a number>` — запрос архива с нечисловым max_items;
появляется при логине, не мешает чату. Проверить настройку MAM (mod_mam) в Prosody 0.11.9.
- ⚠️ ЗАЛИПШИЙ localStorage браузера: мог остаться старый JID `admin@chat.nixg.ru` → Converse
шлёт host-unknown (Prosody не знает chat.nixg.ru). Лечится: localStorage.removeItem("conversejs-session-jid")
или вход в инкогнито. На новом JID admin@nixg.ru работает.
- SRV _xmpp-client-websocket._tcp.nixg.ru всё ещё → chat.nixg.ru (надо → xmpp.nixg.ru.) — TODO у пользователя.
### История гипотез (для справки — была неверна)
- H1 (assets_path=/dist), H2 (опции initialize), H3 (способ подключения) — НЕ были причиной;
реальная причина — ESM-природа v14 + отсутствие type="module" и полного dist.
## 3. КАК ЭТО БЫЛО ИСПРАВЛЕНО (команды, реально сработавшие)
```bash
# 1. Скачать официальный релиз v14 (в нём ПОЛНАЯ dist-структура с чанками)
cd /tmp && curl -sL -o converse-14.0.0.tgz \
https://github.com/conversejs/converse/releases/download/v14.0.0/converse.js-14.0.0.tgz
mkdir -p converse-extract && tar xzf converse-14.0.0.tgz -C converse-extract
mkdir -p /opt/icq/webchat/dist && cp -r converse-extract/package/dist/* /opt/icq/webchat/dist/
# 2. index.html: подключить КАК ES-МОДУЛЬ (ключ!):
# <script type="module" src="/dist/converse.min.js"></script>
# <script type="module">
# import converse from '/dist/converse.min.js';
# converse.initialize({ websocket_url: 'wss://chat.nixg.ru/xmpp-websocket', view_mode: 'fullscreen', auto_login: false, theme: 'concord', i18n: 'ru', show_controlbox_by_default: true });
# </script>
# 3. Проверка headless (без установки новых пакетов — chromium уже в snap):
chromium --headless --no-sandbox --disable-gpu --virtual-time-budget=15000 --dump-dom https://chat.nixg.ru/ | grep converse-login-form
# → если есть converse-login-form — UI отрендерен
# Реальный вход: camofox-browser open https://chat.nixg.ru/ + eval (см. ниже)
```
Диагностика console-ошибок headless:
```bash
# до фикса давало: Uncaught SyntaxError: Cannot use 'import.meta' outside a module
chromium --headless --no-sandbox --disable-gpu --enable-logging=stderr --virtual-time-budget=20000 --dump-dom https://chat.nixg.ru/ 2>&1 | grep -iE 'CONSOLE|Uncaught'
# удобнее: camofox-browser (npm -g) → open/eval/console — см. /tmp/icu-* в WALKTHROUGH
```
## 4. ДАЛЬНЕЙШИЕ ЗАДАЧИ (после фикса страницы)
- [x] Обновить SRV в панели Jino: _xmpp-client/_xmpp-server/_xmpp-client-websocket._tcp.nixg.ru → xmpp.nixg.ru. ✅ (2026-08-28, подтверждено dig @1.1.1.1)
- [x] **Заменить самоподписанный cert Prosody (c2s/s2s 5222/5269) для nixg.ru на настоящий LE — ВЫПОЛНЕНО ✅ (2026-08-28)**
- certbot 2.9.0 (apt) на bigbox; выпуск: `sudo certbot certonly --manual --preferred-challenges dns -d nixg.ru -d xmpp.nixg.ru --manual-auth-hook /bin/true --manual-cleanup-hook /bin/true`
- TXT-записи `_acme-challenge.nixg.ru` и `_acme-challenge.xmpp.nixg.ru` в панели Jino (вручную)
- Сертификат: /etc/letsencrypt/live/nixg.ru/ → скопирован в /opt/icq/certs/nixg.ru.{crt,key}
- Проверено снаружи: c2s 5222 и s2s 5269 отдают LE (issuer=Let's Encrypt, SAN nixg.ru+xmpp.nixg.ru), TLS 1.3
- Истекает 2026-11-26; certbot настроил автопродление — НО при dns-01 вручную автопродление НЕ сработает без повторного добавления TXT! Продление: `sudo certbot renew --manual-auth-hook /bin/true --manual-cleanup-hook /bin/true` (после добавления TXT). TODO: настроить напоминание/скрипт.
- [ ] HTTP Upload (XEP-0363): установить mod_http_upload из prosody-modules
(в docker Prosody 0.11.9 модуля НЕТ). Путь модулей: ./modules в compose.
Подробности в WALKTHROUGH.md раздел 7.
- [ ] OMEMO (сквозное шифрование) — Проверить включение модуля, клиенты.
- [ ] Бот-книгоискатель (slixmpp + OPDS) — Этап 3 PRD.
- [ ] Мосты mautrix (Telegram/WhatsApp) — Этап 4; нужен external component (mod_component).
- [ ] Push-уведомления (APNs/FCM).
## 5. Полезные команды (кратко)
```bash
cd /opt/icq && docker compose ps
docker logs icq-prosody --tail 50
docker logs icq-webchat --since 5m
docker exec icq-prosody prosodyctl check config
docker exec icq-prosody prosodyctl register admin nixg.ru 'ПАРОЛЬ'
# проверка WS рукоятки (python, см. прошлые сессии /tmp/wstest)
```
Проверка входа по WebSocket (slixmpp, ssl не проверяется):
```python
bot = slixmpp.ClientXMPP('admin@nixg.ru','ПАРОЛЬ')
import ssl; ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
bot.ssl_context = ctx
bot.connect(('wss://xmpp.nixg.ru/xmpp-websocket',))
```
+169
View File
@@ -0,0 +1,169 @@
# Развёртывание XMPP-мессенджера ICQ на chat.nixg.ru — Полный разбор
> Итоговый документ опыта. Дата: 2026-08-28.
> Команда: `bigbox` (внутренний сервер, /opt/icq) + `vps02` (публичный вход, Caddy + WireGuard).
> Домен: **chat.nixg.ru** · Протокол: **XMPP** · Сервер: **Prosody 0.11.9** (Docker).
----------------------------------------------------------------------
## 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), wss:443 (веб)
│
▼
[vps02: 87.242.100.206] ← публичный IP, Caddy (TLS/443) + iptables DNAT (5222/5269)
│ 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, 5269, 5280)
└─ icq-webchat — nginx (8081): статика Converse.js + прокси WebSocket на Prosody
```
- **bigbox не имеет публичного IP** — весь внешний трафик идёт через vps02.
- **Caddy** сам выпускает Let's Encrypt для 443 и проксирует WebSocket/HTTP.
- **5222/5269** (XMPP-клиенты и федерация) — DNAT в iptables на vps02 → 10.8.0.2.
- Порт **5280** (Prosody HTTP: WebSocket/BOSH) наружу НЕ торчит — веб-клиент ходит через
Caddy на 443 (wss://), а боты/внутренние сервисы — напрямую.
## 3. Что сделано по шагам (с командами)
### 3.1 DNS (Jino, панель домена nixg.ru)
| Запись | Тип | Значение |
|--------|-----|----------|
| `chat.nixg.ru` | A | `87.242.100.206` |
| `_xmpp-client._tcp.nixg.ru` | SRV | `0 5 5222 chat.nixg.ru` |
| `_xmpp-server._tcp.nixg.ru` | SRV | `0 5 5269 chat.nixg.ru` |
Проверка: `host -t SRV _xmpp-client._tcp.nixg.ru`
**Засада Jino**: панель склеивает поля в одну строку (`_xmpp-client._tcp.nixg.ruIN SRV0 5 5222 chat.nixg.ru`).
Решение: заполнять поля формы по отдельности: имя=`_xmpp-client._tcp`, приоритет=`0`, вес=`5`, порт=`5222`, цель=`chat.nixg.ru.` (FQDN с точкой). Результат в DNS всё равно корректный.
SRV-записи критичны для федерации: без них другие XMPP-серверы не найдут нас.
### 3.2 Prosody на bigbox (/opt/icq)
- `docker-compose.yml` — сервис `prosody` (prosody/prosody:latest), порты 5222/5269/5280/5281,
volumes: ./data, ./config, ./certs, ./modules, ./logs.
- Конфиг `config/prosody.cfg.lua`:
- VirtualHost `chat.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.chat.nixg.ru`
- `cross_domain_websocket = { "https://chat.nixg.ru" }` — разрешить WebSocket с нашего домена
- Логи — в файлы (не консоль), т.к. docker-образ по умолчанию пишет в файлы.
- `https_ports` по умолчанию 5281 — даёт ошибку «No certificate present» (нет HTTPS-серта на 5281), не мешает.
- Регистрация админа: `docker exec icq-prosody prosodyctl register admin chat.nixg.ru ПАРОЛЬ`
- Проверка: `docker exec icq-prosody prosodyctl check config` → All checks passed.
**Тонкости Prosody 0.11.9 в docker-образе**:
- Нет community-модулей: `http_upload` (XEP-0363), `smacks`, `s2s_bidi`, `xmpp_component`.
Их нет в `/usr/lib/prosody/modules/`. Для http_upload нужна установка prosody-modules (см. раздел 7).
- Порты 5222/5269/5280 слушаются на 0.0.0.0 — трафик снаружи приходит через WG.
### 3.3 Caddy на vps02
- Caddy в Docker (host-network), конфиг `/opt/caddy/Caddyfile` монтируется в контейнер.
- Блок для chat.nixg.ru: `reverse_proxy 10.8.0.2:8081` (веб-клиент+WS через nginx на bigbox).
- **Засада с 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..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 (только TCP).
### 3.5 Веб-клиент (Converse.js v14 + nginx)
- `/opt/icq/webchat`: `index.html`, `converse.min.js` (1.3MB, v14.0.0), `converse.min.css` (545KB) — скачаны локально с CDN (чтобы не зависеть от cdn.conversejs.org — может резаться из РФ).
- `ngnix.conf`: `server 8081`, root `/usr/share/nginx/html`; `location /xmpp-websocket { proxy_pass http://prosody:5280/xmpp-websocket; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }`; `location / { try_files $uri $uri/ /index.html; }`.
- Docker-compose: сервис `webchat` (nginx:alpine), ports `8081:8081`, зависит от prosody.
**Критическая засада веб-клиента**: в Converse.js v14 опция WebSocket называется **`websocket_url`**,
НЕ `bosh_service_url`! Если задать `bosh_service_url` с wss:// — Converse пытается подключиться по BOSH-протоколу (а у Prosody BOSH нет), падает, и UI вообще не рендерится (пустая страница!). В v14 `view_mode: 'fullscreen'` — дефолт.
## 4. Проверка работоспособности (извне, с реалного хоста — WSL/телефон)
```bash
nc -vz 87.242.100.206 5222 # xmpp-client: open
nc -vz 87.242.100.206 5269 # xmpp-server: open
curl -skI https://chat.nixg.ru/ # 200, HTML
curl -sk https://chat.nixg.ru/converse.min.js # 200, JS
# 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, телефон по мобильной сети).
## 5. Веб-клиент Converse.js — настройки
```js
converse.initialize({
websocket_url: 'wss://chat.nixg.ru/xmpp-websocket', // ОБЯЗАТЕЛЬНО websocket_url!
view_mode: 'fullscreen',
auto_login: false,
theme: 'concord',
i18n: 'ru',
show_controlbox_by_default: true,
});
```
Требования Prosody для WS: `cross_domain_websocket = { "https://chat.nixg.ru" }` в конфиге.
## 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.
## 7. TODO (следующие шаги)
> ⚡ Текущий блокер и пошаговый план: **[STATUS.md](STATUS.md)** — пустая страница Converse.js.
- [ ] **Починить пустую страницу веб-клиента** (см. STATUS.md: разложить полный дистрибутив Converse.js
с /dist-ассетами, получить console-ошибки headless-браузером).
- [ ] **Установить модуль HTTP Upload (XEP-0363)** для Prosody 0.11.9:
community-модуль mod_http_upload из prosody-modules (https://modules.prosody.im/).
Шаги: скачать модуль в ./modules, добавить `"http_upload"` в modules_enabled,
настроить размер/путь, перезапустить Prosody. Нужен для отправки файлов в чат
(книги бота, фото). Также проверить mod_http_upload_external.
- [ ] OMEMO (сквозное шифрование) — модуль + настройка клиентов.
- [ ] Бот-книгоискатель (slixmpp + OPDS) — Этап 3 PRD.
- [ ] Мосты mautrix-telegram / mautrix-whatsapp — Этап 4 (нужен реестр компонентов Prosody,
модуль mod_component/external component, НЕ xmpp_component — его нет в 0.11.9).
- [ ] Настоящие сертификаты для 5222/5269 (dns-01) вместо самоподписанных.
- [ ] Push-уведомления APNs/FCM (публичный push-шлюз для Prosody).
## 8. Полезные команды
```bash
cd /opt/icq
docker compose ps
docker logs icq-prosody --tail 50
docker exec icq-prosody prosodyctl check config
docker exec icq-prosody prosodyctl register admin chat.nixg.ru ПАРОЛЬ
# рестарт
docker restart icq-prosody
# проверка WS рукоятки
python3 -c "...socket..."
```
+120
View File
@@ -0,0 +1,120 @@
-- Prosody configuration for ICQ (nixg.ru)
-- Домен: nixg.ru (JID юзеров user@nixg.ru)
-- Веб-клиент (Converse.js) остаётся на https://chat.nixg.ru, WS → xmpp.nixg.ru
-- Каталог: /opt/icq
-- Пути
data_path = "/var/lib/prosody"
plugin_paths = { "/etc/prosody/modules" }
-- Логи (глобальная секция)
log = {
info = "/var/log/prosody/prosody.log";
error = "/var/log/prosody/prosody.err";
-- info = "console"; -- раскомментируй для вывода в docker logs
}
-- Администрирование
admins = { "admin@nixg.ru" }
-- Модули (включая стандартные)
modules_enabled = {
-- Generally required
"roster"; -- Список контактов
"saslauth"; -- Аутентификация
"tls"; -- TLS
"dialback"; -- s2s dialback
"disco"; -- Service discovery
"carbons"; -- Копии сообщений на все устройства
"pep"; -- Personal eventing (OMEMO, avatars)
"private"; -- Приватные хранилища XML
"blocklist"; -- Чёрный список
"vcard4"; -- vCard 4
"vcard_legacy"; -- vCard 2 (для старых клиентов)
"version"; -- Версия сервера
"uptime"; -- Время работы
"time"; -- Время
"ping"; -- XMPP Ping
"register"; -- In-band registration (для первого пользователя)
"adhoc"; -- ad-hoc команды
"admin_adhoc"; -- админ-команды
-- Messaging / Chat
"csi"; -- Client State Indication (экономия батареи)
-- "smacks"; -- Stream Management (community-модуль, пока нет)
"offline"; -- Хранение оффлайн-сообщений
"presence"; -- Присутствие
-- Message archive
"mam"; -- Message Archive Management (XEP-0313)
-- HTTP (для веб-клиентов)
"websocket"; -- WebSocket для веб-клиентов
-- Прочее
"server_contact_info"; -- Контактная информация сервера
}
-- Отключить модули, которые не нужны/мешают
modules_disabled = {
-- "s2s"; -- (раскомментируй, чтобы отключить федерацию, но она нужна!)
}
-- Порты и интерфейсы
c2s_ports = { 5222 }
s2s_ports = { 5269 }
-- Слушать на всех интерфейсах (в контейнере)
c2s_interface = "0.0.0.0"
s2s_interface = "0.0.0.0"
-- HTTP (websocket + BOSH + upload)
http_ports = { 5280 }
http_interfaces = { "0.0.0.0" }
-- HTTPS-порт для HTTP Upload (mod_http_upload требует TLS) — с LE-сертификатом
https_ports = { 5281 }
https_interfaces = { "0.0.0.0" }
-- Глобальный SSL для HTTPS-порта (LE-серт nixg.ru, SAN: nixg.ru + xmpp.nixg.ru)
ssl = {
key = "/etc/prosody/certs/nixg.ru.key";
certificate = "/etc/prosody/certs/nixg.ru.crt";
}
-- Разрешить WebSocket-подключения с нашего веб-клиента (страница на chat.nixg.ru)
cross_domain_websocket = { "https://chat.nixg.ru", "https://xmpp.nixg.ru" }
-- Домен (JID юзеров: user@nixg.ru)
VirtualHost "nixg.ru"
-- Аутентификация: внутренняя (mod_auth_internal_hashed)
authentication = "internal_hashed"
-- Хранилище (встроенное, файлы)
storage = "internal"
-- Включить регистрацию (in-band)
allow_registration = true
-- TLS: сертификаты для домена (для c2s/s2s 5222/5269)
ssl = {
key = "/etc/prosody/certs/nixg.ru.key";
certificate = "/etc/prosody/certs/nixg.ru.crt";
-- Для федерации нужен полный chain + private key
}
-- MUC-компонент (групповые чаты) conference.nixg.ru
Component "conference.nixg.ru" "muc"
name = "ICQ Family Chat"
restrict_room_creation = false -- все могут создавать комнаты
max_history_messages = 200
-- HTTP Upload (XEP-0363) — mod_http_upload (community-модуль, установлен из apt prosody-modules,
-- скопирован в ./modules/mod_http_upload/. Работает как Component (НЕ в modules_enabled).
Component "upload.nixg.ru" "http_upload"
http_upload_file_size_limit = 10 * 1024 * 1024 -- 10 MB (лимит HTTP-парсера Prosody)
http_upload_expire_after = 7 * 24 * 60 * 60 -- хранить 7 дней
http_upload_require_authentication = true -- только авторизованные
http_upload_path = "/var/lib/prosody/http_upload"
-- Внешний URL (без :5281) — Caddy на vps02 проксирует upload.nixg.ru → 10.8.0.2:5281
http_external_url = "https://upload.nixg.ru"
-- Мосты (mautrix) добавим на Этапе 3 — компоненты регистрируются через
-- Prosody mod_component / отдельную конфигурацию. Пока не подключаем.
-- Component "telegram.chat.nixg.ru" "xmpp_component"
-- Component "whatsapp.chat.nixg.ru" "xmpp_component"
+39
View File
@@ -0,0 +1,39 @@
version: "3.9"
services:
prosody:
image: prosody/prosody:latest
container_name: icq-prosody
restart: unless-stopped
hostname: chat.nixg.ru
volumes:
- ./data:/var/lib/prosody
- ./config:/etc/prosody
- ./certs:/etc/prosody/certs
- ./modules:/etc/prosody/modules # для кастомных модулей (http_upload и т.п.)
- ./logs:/var/log/prosody
ports:
- "5222:5222" # c2s (клиенты)
- "5269:5269" # s2s (федерация)
- "5280:5280" # BOSH / websocket
- "5281:5281" # websocket TLS (если нужен)
environment:
- LOCAL=nixg.ru
- DOMAIN=nixg.ru
- "XMPP_DOMAIN=nixg.ru"
# Для проброса трафика извне через WireGuard на 5222/5269 нужно,
# чтобы контейнер слушал на хосте. Пока оставляем привязку на все интерфейсы.
# Caddy (websocket) идёт на 5280/5281.
# Примечание: для федерации также открыть 5269 на VPS02 (форвард на bigbox).
webchat:
image: nginx:alpine
container_name: icq-webchat
restart: unless-stopped
volumes:
- ./webchat/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./webchat:/usr/share/nginx/html:ro
ports:
- "8081:8081" # веб-клиент (Caddy на vps02 проксирует на 10.8.0.2:8081)
depends_on:
- prosody
+450
View File
@@ -0,0 +1,450 @@
-- mod_http_upload
--
-- Copyright (C) 2015-2018 Kim Alvefur
--
-- This file is MIT/X11 licensed.
--
-- Implementation of HTTP Upload file transfer mechanism used by Conversations
--
-- depends
module:depends("http");
module:depends("disco");
if module:http_url():match("^http://") then
error("File upload MUST happen with TLS but it isn’t enabled, see https://prosody.im/doc/http for how to fix this issue");
end
-- imports
local st = require"util.stanza";
local lfs = require"lfs";
local url = require "socket.url";
local dataform = require "util.dataforms".new;
local datamanager = require "util.datamanager";
local array = require "util.array";
local t_concat = table.concat;
local t_insert = table.insert;
local s_upper = string.upper;
local httpserver = require "net.http.server";
local have_id, id = pcall(require, "util.id"); -- Only available in 0.10+
local uuid = require"util.uuid".generate;
local jid = require "util.jid";
if have_id then
uuid = id.medium;
end
local function join_path(...) -- COMPAT util.path was added in 0.10
return table.concat({ ... }, package.config:sub(1,1));
end
-- config
local file_size_limit = module:get_option_number(module.name .. "_file_size_limit", 1024 * 1024); -- 1 MB
local quota = module:get_option_number(module.name .. "_quota");
local max_age = module:get_option_number(module.name .. "_expire_after");
local access = module:get_option_set(module.name .. "_access", {});
--- sanity
local parser_body_limit = module:context("*"):get_option_number("http_max_content_size", 10*1024*1024);
if file_size_limit > parser_body_limit then
module:log("warn", "%s_file_size_limit exceeds HTTP parser limit on body size, capping file size to %d B",
module.name, parser_body_limit);
file_size_limit = parser_body_limit;
end
if prosody.hosts[module.host].type == "local" then
module:log("warn", "mod_%s loaded on a user host, this may be incompatible with some client software, see docs for correct usage", module.name);
end
local http_files;
if prosody.process_type == "prosody" then
http_files = require "net.http.files";
else
http_files = module:depends"http_files";
end
local mime_map = module:shared("/*/http_files/mime").types;
if not mime_map then
mime_map = {
html = "text/html", htm = "text/html",
xml = "application/xml",
txt = "text/plain",
css = "text/css",
js = "application/javascript",
png = "image/png",
gif = "image/gif",
jpeg = "image/jpeg", jpg = "image/jpeg",
svg = "image/svg+xml",
};
module:shared("/*/http_files/mime").types = mime_map;
local mime_types, err = io.open(module:get_option_path("mime_types_file", "/etc/mime.types", "config"), "r");
if mime_types then
local mime_data = mime_types:read("*a");
mime_types:close();
setmetatable(mime_map, {
__index = function(t, ext)
local typ = mime_data:match("\n(%S+)[^\n]*%s"..(ext:lower()).."%s") or "application/octet-stream";
t[ext] = typ;
return typ;
end
});
end
end
-- namespaces
local namespace = "urn:xmpp:http:upload:0";
local legacy_namespace = "urn:xmpp:http:upload";
-- identity and feature advertising
module:add_identity("store", "file", module:get_option_string("name", "HTTP File Upload"));
module:add_feature(namespace);
module:add_feature(legacy_namespace);
module:add_extension(dataform {
{ name = "FORM_TYPE", type = "hidden", value = namespace },
{ name = "max-file-size", type = "text-single" },
}:form({ ["max-file-size"] = ("%d"):format(file_size_limit) }, "result"));
module:add_extension(dataform {
{ name = "FORM_TYPE", type = "hidden", value = legacy_namespace },
{ name = "max-file-size", type = "text-single" },
}:form({ ["max-file-size"] = ("%d"):format(file_size_limit) }, "result"));
-- state
local pending_slots = module:shared("upload_slots");
local storage_path = module:get_option_string(module.name .. "_path", join_path(prosody.paths.data, module.name));
lfs.mkdir(storage_path);
local function expire(username, host)
if not max_age then return true; end
local uploads, err = datamanager.list_load(username, host, module.name);
if err then return false, err; end
if not uploads then return true; end
uploads = array(uploads);
local expiry = os.time() - max_age;
local upload_window = os.time() - 900;
local before = #uploads;
uploads:filter(function (item)
local filename = item.filename;
if item.dir then
filename = join_path(storage_path, item.dir, item.filename);
end
if item.time < expiry then
local deleted, whynot = os.remove(filename);
if not deleted then
module:log("warn", "Could not delete expired upload %s: %s", filename, whynot or "delete failed");
end
os.remove(filename:match("^(.*)[/\\]"));
return false;
elseif item.time < upload_window and not lfs.attributes(filename) then
return false; -- File was not uploaded or has been deleted since
end
return true;
end);
local after = #uploads;
if before == after then return true end -- nothing changed, skip write
return datamanager.list_store(username, host, module.name, uploads);
end
local function check_quota(username, host, does_it_fit)
if not quota then return true; end
local uploads, err = datamanager.list_load(username, host, module.name);
if err then
return false;
elseif not uploads then
if does_it_fit then
return does_it_fit < quota;
end
return true;
end
local sum = does_it_fit or 0;
for _, item in ipairs(uploads) do
sum = sum + item.size;
end
return sum < quota;
end
local measure_slot = function () end
if module.measure then
-- COMPAT 0.9
-- module:measure was added in 0.10
measure_slot = module:measure("slot", "sizes");
end
local function handle_request(origin, stanza, xmlns, filename, filesize)
local username, host = origin.username, origin.host;
local user_bare = jid.bare(stanza.attr.from);
local user_host = jid.host(user_bare);
-- local clients or whitelisted jids/hosts only
if not (origin.type == "c2s" or access:contains(user_bare) or access:contains(user_host)) then
module:log("debug", "Request for upload slot from a %s", origin.type);
return nil, st.error_reply(stanza, "cancel", "not-authorized");
end
-- validate
if not filename or filename:find("/") then
module:log("debug", "Filename %q not allowed", filename or "");
return nil, st.error_reply(stanza, "modify", "bad-request", "Invalid filename");
end
expire(username, host);
if not filesize then
module:log("debug", "Missing file size");
return nil, st.error_reply(stanza, "modify", "bad-request", "Missing or invalid file size");
elseif filesize > file_size_limit then
module:log("debug", "File too large (%d > %d)", filesize, file_size_limit);
return nil, st.error_reply(stanza, "modify", "not-acceptable", "File too large")
:tag("file-too-large", {xmlns=xmlns})
:tag("max-file-size"):text(("%d"):format(file_size_limit));
elseif not check_quota(username, host, filesize) then
module:log("debug", "Upload of %dB by %s would exceed quota", filesize, user_bare);
return nil, st.error_reply(stanza, "wait", "resource-constraint", "Quota reached");
end
local random_dir = uuid();
local created, err = lfs.mkdir(join_path(storage_path, random_dir));
if not created then
module:log("error", "Could not create directory for slot: %s", err);
return nil, st.error_reply(stanza, "wait", "internal-server-error");
end
local ok = datamanager.list_append(username, host, module.name, {
filename = filename, dir = random_dir, size = filesize, time = os.time() });
if not ok then
return nil, st.error_reply(stanza, "wait", "internal-server-error");
end
local slot = random_dir.."/"..filename;
pending_slots[slot] = user_bare;
module:add_timer(900, function()
pending_slots[slot] = nil;
end);
measure_slot(filesize);
origin.log("debug", "Given upload slot %q", slot);
local base_url = module:http_url();
local slot_url = url.parse(base_url);
slot_url.path = url.parse_path(slot_url.path or "/");
t_insert(slot_url.path, random_dir);
t_insert(slot_url.path, filename);
slot_url.path.is_directory = false;
slot_url.path = url.build_path(slot_url.path);
slot_url = url.build(slot_url);
return slot_url;
end
-- hooks
module:hook("iq/host/"..namespace..":request", function (event)
local stanza, origin = event.stanza, event.origin;
local request = stanza.tags[1];
local filename = request.attr.filename;
local filesize = tonumber(request.attr.size);
local slot_url, err = handle_request(origin, stanza, namespace, filename, filesize);
if not slot_url then
origin.send(err);
return true;
end
local reply = st.reply(stanza)
:tag("slot", { xmlns = namespace })
:tag("get", { url = slot_url }):up()
:tag("put", { url = slot_url }):up()
:up();
origin.send(reply);
return true;
end);
module:hook("iq/host/"..legacy_namespace..":request", function (event)
local stanza, origin = event.stanza, event.origin;
local request = stanza.tags[1];
local filename = request:get_child_text("filename");
local filesize = tonumber(request:get_child_text("size"));
local slot_url, err = handle_request(origin, stanza, legacy_namespace, filename, filesize);
if not slot_url then
origin.send(err);
return true;
end
local reply = st.reply(stanza)
:tag("slot", { xmlns = legacy_namespace })
:tag("get"):text(slot_url):up()
:tag("put"):text(slot_url):up()
:up();
origin.send(reply);
return true;
end);
local measure_upload = function () end
if module.measure then
-- COMPAT 0.9
-- module:measure was added in 0.10
measure_upload = module:measure("upload", "sizes");
end
-- http service
local function set_cross_domain_headers(response)
local headers = response.headers;
headers.access_control_allow_methods = "GET, PUT, OPTIONS";
headers.access_control_allow_headers = "Content-Type";
headers.access_control_max_age = "7200";
headers.access_control_allow_origin = response.request.headers.origin or "*";
return response;
end
local function upload_data(event, path)
set_cross_domain_headers(event.response);
local uploader = pending_slots[path];
if not uploader then
module:log("warn", "Attempt to upload to unknown slot %q", path);
return; -- 404
end
local random_dir, filename = path:match("^([^/]+)/([^/]+)$");
if not random_dir then
module:log("warn", "Invalid file path %q", path);
return 400;
end
if #event.request.body > file_size_limit then
module:log("warn", "Uploaded file too large %d bytes", #event.request.body);
return 400;
end
pending_slots[path] = nil;
local full_filename = join_path(storage_path, random_dir, filename);
if lfs.attributes(full_filename) then
module:log("warn", "File %s exists already, not replacing it", full_filename);
return 409;
end
local fh, ferr = io.open(full_filename, "w");
if not fh then
module:log("error", "Could not open file %s for upload: %s", full_filename, ferr);
return 500;
end
local ok, err = fh:write(event.request.body);
if not ok then
module:log("error", "Could not write to file %s for upload: %s", full_filename, err);
os.remove(full_filename);
return 500;
end
ok, err = fh:close();
if not ok then
module:log("error", "Could not write to file %s for upload: %s", full_filename, err);
os.remove(full_filename);
return 500;
end
measure_upload(#event.request.body);
module:log("info", "File uploaded by %s to slot %s", uploader, random_dir);
return 201;
end
-- FIXME Duplicated from net.http.server
local codes = require "net.http.codes";
local headerfix = setmetatable({}, {
__index = function(t, k)
local v = "\r\n"..k:gsub("_", "-"):gsub("%f[%w].", s_upper)..": ";
t[k] = v;
return v;
end
});
local function send_response_sans_body(response, body)
if response.finished then return; end
response.finished = true;
response.conn._http_open_response = nil;
local status_line = "HTTP/"..response.request.httpversion.." "..(response.status or codes[response.status_code]);
local headers = response.headers;
if type(body) == "string" then
headers.content_length = #body;
elseif io.type(body) == "file" then
headers.content_length = body:seek("end");
body:close();
end
local output = { status_line };
for k,v in pairs(headers) do
t_insert(output, headerfix[k]..v);
end
t_insert(output, "\r\n\r\n");
-- Here we *don't* add the body to the output
response.conn:write(t_concat(output));
if response.on_destroy then
response:on_destroy();
response.on_destroy = nil;
end
if response.persistent then
response:finish_cb();
else
response.conn:close();
end
end
local serve_uploaded_files = http_files.serve({ path = storage_path, mime_map = mime_map });
local function serve_head(event, path)
set_cross_domain_headers(event.response);
event.response.send = send_response_sans_body;
event.response.send_file = send_response_sans_body;
return serve_uploaded_files(event, path);
end
if httpserver.send_head_response then
-- Prosody will take care of HEAD requests since hg:3f4c25425589
serve_head = nil
end
local function serve_hello(event)
event.response.headers.content_type = "text/html;charset=utf-8"
return "<!DOCTYPE html>\n<h1>Hello from mod_"..module.name.." on "..module.host.."!</h1>\n";
end
module:provides("http", {
route = {
["GET"] = serve_hello;
["GET /"] = serve_hello;
["GET /*"] = serve_uploaded_files;
["HEAD /*"] = serve_head;
["PUT /*"] = upload_data;
["OPTIONS /*"] = function (event)
set_cross_domain_headers(event.response);
return "";
end;
};
});
module:log("info", "URL: <%s> - Ensure this can be reached by users", module:http_url());
module:log("info", "Storage path: '%s'", storage_path);
function module.command(args)
datamanager = require "core.storagemanager".olddm;
-- luacheck: ignore 421/user
if args[1] == "expire" and args[2] then
local split = require "util.jid".prepped_split;
for i = 2, #args do
local user, host = split(args[i]);
if user then
assert(expire(user, host));
else
for user in assert(datamanager.users(host, module.name, "list")) do
expire(user, host);
end
end
end
else
print("prosodyctl mod_http_upload expire [host or user@host]+")
print("\tProcess upload expiry for the given list of hosts and/or users");
return 1;
end
end
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ICQ Family Chat</title>
<meta name="theme-color" content="#2c3e50">
<!-- Converse.js v14 (локально, ESM-сборка из /dist) -->
<link rel="stylesheet" type="text/css" href="/dist/converse.min.css">
</head>
<body>
<!-- Converse.js рендерит интерфейс в этот контейнер -->
<div id="conversejs"></div>
<!-- ВАЖНО: v14 собирается как ES-модуль (использует import.meta и динамические
import() для чанков). Подключать ТОЛЬКО через type="module", иначе
SyntaxError: Cannot use 'import.meta' outside a module. -->
<script type="module" src="/dist/converse.min.js"></script>
<script type="module">
import converse from '/dist/converse.min.js';
converse.initialize({
// WebSocket-адрес XMPP-сервера (ВАЖНО: websocket_url, НЕ bosh_service_url —
// иначе Converse пробует подключиться по BOSH-протоколу и падает).
// Страница живёт на chat.nixg.ru, но XMPP-домен теперь nixg.ru → WS на xmpp.nixg.ru.
// Юзеры логинятся как user@nixg.ru.
websocket_url: 'wss://xmpp.nixg.ru/xmpp-websocket',
// Полноэкранный режим (дефолт в v14)
view_mode: 'fullscreen',
// Авторизация вручную (форма логина)
auto_login: false,
// Тема
theme: 'concord',
// Русский язык
i18n: 'ru',
// Показать форму входа сразу
show_controlbox_by_default: true,
});
</script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
# ICQ WebChat — nginx, отдаёт Converse.js и проксирует WebSocket на Prosody
# Порт 8081 (внутри сети icq_default), Caddy на vps02 проксирует на 10.8.0.2:8081
server {
listen 8081;
server_name chat.nixg.ru xmpp.nixg.ru;
# Статика Converse.js
root /usr/share/nginx/html;
index index.html;
# WebSocket XMPP (Converse.js подключается к wss://chat.nixg.ru/xmpp-websocket)
location /xmpp-websocket {
proxy_pass http://prosody:5280/xmpp-websocket;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s; # долгоживущий WS
proxy_send_timeout 3600s;
}
# BOSH (если понадобится)
location /http-bind {
proxy_pass http://prosody:5280/http-bind;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
# XEP-0156: host-meta (JSON) — чтобы клиенты находили WebSocket автоматически
location = /.well-known/host-meta {
default_type application/jrd+json;
return 200 '{"links":[{"rel":"urn:xmpp:alt-connections:websocket","href":"wss://xmpp.nixg.ru/xmpp-websocket"},{"rel":"urn:xmpp:alt-connections:xbosh","href":"https://xmpp.nixg.ru/http-bind"}]}';
}
location = /.well-known/host-meta.json {
default_type application/jrd+json;
return 200 '{"links":[{"rel":"urn:xmpp:alt-connections:websocket","href":"wss://xmpp.nixg.ru/xmpp-websocket"},{"rel":"urn:xmpp:alt-connections:xbosh","href":"https://xmpp.nixg.ru/http-bind"}]}';
}
# Основная страница
location / {
try_files $uri $uri/ /index.html;
}
}