commit a104b6d764e74fd7a7330a3154d78fff7073396b Author: kpa39l Date: Fri Aug 28 19:36:10 2026 +0000 first_commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9732f5f --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..fb1b312 --- /dev/null +++ b/MIGRATION.md @@ -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) \ No newline at end of file diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..1f8449f --- /dev/null +++ b/PRD.md @@ -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-клиентами — подготовить инструкцию. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5285b81 --- /dev/null +++ b/README.md @@ -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) \ No newline at end of file diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..17c5391 --- /dev/null +++ b/STATUS.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}`; +- подключение через обычный ` +# + +# 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',)) +``` \ No newline at end of file diff --git a/WALKTHROUGH.md b/WALKTHROUGH.md new file mode 100644 index 0000000..3508ca4 --- /dev/null +++ b/WALKTHROUGH.md @@ -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 — +маршрут ``, пакет не проходит через 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..." +``` \ No newline at end of file diff --git a/config/prosody.cfg.lua b/config/prosody.cfg.lua new file mode 100644 index 0000000..ea9c2da --- /dev/null +++ b/config/prosody.cfg.lua @@ -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" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d53ffcf --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/modules/mod_http_upload/mod_http_upload.lua b/modules/mod_http_upload/mod_http_upload.lua new file mode 100644 index 0000000..b2d52dc --- /dev/null +++ b/modules/mod_http_upload/mod_http_upload.lua @@ -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 "\n

Hello from mod_"..module.name.." on "..module.host.."!

\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 + diff --git a/webchat/index.html b/webchat/index.html new file mode 100644 index 0000000..17ff661 --- /dev/null +++ b/webchat/index.html @@ -0,0 +1,40 @@ + + + + + + ICQ Family Chat + + + + + + +
+ + + + + + \ No newline at end of file diff --git a/webchat/nginx.conf b/webchat/nginx.conf new file mode 100644 index 0000000..54c90a0 --- /dev/null +++ b/webchat/nginx.conf @@ -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; + } +} \ No newline at end of file