mirror of
https://gitverse.ru/kpa39l/icq.git
synced 2026-09-29 18:05:10 +00:00
first_commit
This commit is contained in:
+20
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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-клиентами — подготовить инструкцию.
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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..."
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user