diff --git a/STATUS.md b/STATUS.md index c1943e4..daf64e8 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,6 +1,6 @@ # ICQ XMPP — ТЕКУЩИЙ СТАТУС И ПЛАН (точка входа для новой сессии) -> Обновлено: 2026-08-28 ≈ 20:00 UTC · Сессия: bigbox (/opt/icq) +> Обновлено: 2026-08-29 · Сессия: bigbox (/opt/icq) > ⚠️ МИГРАЦИЯ НА nixg.ru (2026-08-28): JID юзеров теперь `user@nixg.ru`, веб-клиент остаётся на chat.nixg.ru, WS → wss://xmpp.nixg.ru. См. MIGRATION.md. > ✅ Документация полная и актуальная: [WALKTHROUGH.md](WALKTHROUGH.md) (все этапы, команды, засады, TLS, HTTP Upload) · [PRD.md](PRD.md) (требования/архитектура) · [MIGRATION.md](MIGRATION.md) (переезд). @@ -119,8 +119,30 @@ chromium --headless --no-sandbox --disable-gpu --enable-logging=stderr --virtual - НЕБОЛЬШАЯ ЗАСАДА: на bigbox старый DNS-кэш показывал 81.177.135.175 — лечится `sudo systemctl restart systemd-resolved` (flush-caches недостаточно) - Локальный кэш 1.1.1.1 ещё может держать старый IP до TTL — не ошибка -- [ ] OMEMO (сквозное шифрование) — Проверить включение модуля, клиенты. +- [x] **OMEMO (сквозное шифрование) — ВЫПОЛНЕНО ✅ (2026-08-28)** + - Подробности и путь достижения: **WALKTHROUGH.md раздел 9**; скрипт проверки: `scripts/omemo_check.py` (PASSWORD=... запуск). + - Серверный модуль НЕ нужен: XEP-0384 работает на клиентах; Prosody хранит ключи через PEP (уже был включён). + - Converse v14 имеет встроенный OMEMO (libomemo.esm.min.js в dist). Проверено реальной сессией: + веб-чат сгенерировал device id=14035, опубликовал в PEP devicelist + bundles:14035 + (identityKey, 100 preKeys, signedPreKey, signature — подтверждено на диске data/nixg%2eru/pep_*). + - В index.html включено `omemo_default: true` — шифровать по умолчанию, когда контакт поддерживает. + - Другие клиенты (Gajim/Conversations/Dino) подключатся: сервер хранит их OMEMO-бандлы так же. + - Тест slixmpp: publish/retract в PEP-узел devicelist работает (проверено, тестовый device убран). + - ⚠️ Если OMEMO не активируется в UI: очистить localStorage (старый JID admin@chat.nixg.ru) и войти заново. +- [ ] **Резервное копирование проекта на Яндекс.Диск** — по образцу /opt/netbox/backup.sh + (готовый шаблон: локальный BACKUP_DIR + копия на /mnt/yandex-disk + ротация 7/30 дней + проверка mountpoint). + - Что бэкапить (tar czf): `data/` (Prosody: аккаунты, PEP/OMEMO-бандлы, MUC, MAM-архив) — самое ценное; + плюс конфиги (prosody.cfg.lua, docker-compose.yml, .env, webchat/), certs/, все *.md-документы. + - Куда: /opt/icq/backups/ (локально, 7 дней) → /mnt/yandex-disk/backup/icq-backups/ (30 дней). + - Бэкап живого `data/` через tar безопасен (данные Prosody — файлы, остановка контейнера не нужна). + - Cron: ежедневно ~03:00 (рядом с бэкапом NetBox), скрипт /opt/icq/backup.sh по образцу /opt/netbox/backup.sh. - [ ] Бот-книгоискатель (slixmpp + OPDS) — Этап 3 PRD. +- [ ] **Проверить рестарт Prosody с новыми модулями** (2026-08-29): скопированы в ./modules и прописаны в конфиге + mod_vcard_muc + mod_muc_moderation (в MUC conference.nixg.ru), mod_admin_web (global, требует bosh — тоже включён), + mod_http_upload_external (НЕ включён — альтернатива работающему http_upload). После `docker compose restart prosody` + проверить logs: mod_admin_web 2010 года под 0.11 может не загрузиться → тогда закомментировать в конфиге. + Панель /admin раздаётся на :5280 (внутри Docker; наружу не проброшен — при желании добавить admin.nixg.ru в Caddy). + Подробности: WALKTHROUGH.md раздел 10. - [ ] Мосты mautrix (Telegram/WhatsApp) — Этап 4; нужен external component (mod_component). - [ ] Push-уведомления (APNs/FCM). diff --git a/WALKTHROUGH.md b/WALKTHROUGH.md index d3d75ad..5d053a2 100644 --- a/WALKTHROUGH.md +++ b/WALKTHROUGH.md @@ -303,7 +303,169 @@ for port in (5222, 5269): # Ожидание: TLSv1.3, issuer Let's Encrypt ``` -## 9. Полезные команды +## 9. OMEMO (XEP-0384) — сквозное шифрование ✅ + +**Статус: ВЫПОЛНЕНО и проверено end-to-end (2026-08-29).** + +### 9.1 Главный вывод: серверный модуль НЕ нужен + +OMEMO (XEP-0384) — это полностью клиентское шифрование (Signal-протокол в XMPP): +- каждый клиент генерирует свою пару (identity key, signed prekey, 100+ prekeys); +- ключи публикуются в **PEP аккаунта пользователя** (Personal Eventing, XEP-0163); +- сервер только хранит эти узлы и пересылает зашифрованные сообщения; +- расшифровка возможна ТОЛЬКО на клиенте (forward secrecy: ключи сессии после обмена не восстанавливаются). + +Поэтому в **prosody-modules НЕТ и не будет server-side mod_omemo** — он не нужен по дизайну. +Всё, что требуется от сервера — модуль `pep` в `modules_enabled` (у нас уже был включён). + +Ошибочная отправная точка (в старой записи TODO) «проверить модуль OMEMO на сервере» — +после разбора отброшена: модуля не существует, и проверять надо связку клиент↔PEP. + +### 9.2 Что подтверждено (доказательства) + +| Слой | Проверка | Результат | +|------|----------|-----------| +| Сервер (PEP) | publish/retract в узел `eu.siacs.conversations.axolotl.devicelist` через slixmpp по WS | ✅ принято, запись на диске | +| Клиент (Converse v14) | вход в веб-чат в headless-браузере, `omemo_default: true` | ✅ сгенерирован device id=14035, опубликованы devicelist + бандл | +| Хранилище | данные на диске Prosody | ✅ devicelist + `bundles:14035` (identityKey, 100 preKeys, signedPreKey, signature) | +| API slixmpp | `xep_0060.publish(jid, node, id='current', payload=)` | ✅ работает; retract-эквивалент — ре-публикация списка без устройства | + +### 9.3 Проверка серверной части (slixmpp 1.17 + WebSocket) + +Скрипт в проекте: `/opt/icq/scripts/omemo_check.py` (сохранён 2026-08-29, работает из .venv). + +Ключевые моменты API slixmpp 1.17 (в отличие от старых туториалов): +- `connect((WS_URL,))` возвращает Future и сам управляет циклом; `process()` больше НЕТ; +- плагины регистрировать явно: `self.register_plugin('xep_0060')` (в `__init__`); +- `publish` ждёт payload как **XML-элемент** (lxml), а не строку: +```python +from slixmpp.xmlstream import ET +lst = ET.Element('{eu.siacs.conversations.axolotl}list') +ET.SubElement(lst, '{eu.siacs.conversations.axolotl}device', {'id': '7777'}) +await self['xep_0060'].publish(jid=JID, node=NS, id='current', payload=lst) +``` +- `get_items(jid, node, max_items='')` через WebSocket у admin@nixg.ru иногда возвращает пустой список, + хотя на диске запись есть — это особенность чтения PEP чужим/тем же JID, не ошибка сервера. + +Подключение (SSL не проверяется — серт LE, но для надёжности отключаем проверку): +```python +ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE +bot.ssl_context = ctx +fut = bot.connect(('wss://xmpp.nixg.ru/xmpp-websocket',)) +await asyncio.wait_for(fut, timeout=25) +``` + +### 9.4 Проверка клиентской части (Converse v14, headless) + +1. Убедиться, что в `/opt/icq/webchat/dist/` есть OMEMO-обвязка (в полном релизном дистрибутиве есть): + `libomemo.esm.min.js`, `curve25519_compiled.wasm`; в `converse.min.js` есть ключи + `omemo_default`, `omemo_active`, `omemo_store`. +2. В `index.html` добавлено `omemo_default: true` (см. 9.6). +3. Вход в веб-чат: camofox-browser open https://chat.nixg.ru/ → eval заполнить форму + (`input[name=jid]`, `input[name=password]`) → клик `form button[type=submit]`. +4. Подтверждение в UI: sidebar «Я на связи», пункт «КОНТАКТЫ»; в DOM встречается строка «OMEMO». +5. Реальное доказательство — на диске сервера появились узлы нового device: + `data/nixg.ru/pep_eu%2esiacs%2econversations%2eaxolotl%2edevicelist/admin.list` и + `data/nixg.ru/pep_eu%2esiacs%2econversations%2eaxolotl%2ebundles%3a14035/admin.list`. + +### 9.5 Что лежит в хранилище Prosody (как читать узлы PEP) + +``` +/opt/icq/data/nixg%2eru/pep_eu%2esiacs%2econversations%2eaxolotl%2edevicelist/admin.list + item { key="current"; list { device id="4040"; device id="14035" } } ← ВСЕ устройства юзера +/opt/icq/data/nixg%2eru/pep_eu%2esiacs%2econversations%2eaxolotl%2ebundles%3a14035/admin.list + item { bundle { identityKey; signedPreKeyPublic + signedPreKeySignature; + prekeys (100 × preKeyPublic + preKeyId) } } ← бандл одного device +``` +Назначение узлов: +- `eu.siacs.conversations.axolotl.devicelist` — список device-id аккаунта (публикуется при каждом входе); +- `eu.siacs.conversations.axolotl.bundles:` — ключи конкретного устройства; +- `urn:xmpp:omemo:2:devices` — устройства для OMEMO 2.0-клиентов (новый стандарт). + +Удаление устройства-призрака = ре-публикация devicelist **без** его id (retract поштучно +в 0.11 работает плохо — проще переписать весь список). + +### 9.6 Изменение `index.html` (Converse initialize) + +```js +converse.initialize({ + websocket_url: 'wss://xmpp.nixg.ru/xmpp-websocket', + ... + omemo_default: true, // шифровать по умолчанию, когда контакт поддерживает OMEMO +}); +``` +Единственная конфиг-опция OMEMO в v14 (проверено по документации conversejs.org/docs/configuration/): +`omemo_default` (default false). Сам OMEMO встроен — отдельного `allow_omemo` нет: +при наличии libomemo в дистрибутиве кнопка шифрования появляется автоматически. + +### 9.7 Засады OMEMO (зафиксировано на практике) + +1. **Старый JID в localStorage**: если браузер помнит `admin@chat.nixg.ru` — Prosody отвечает + `host-unknown`, Converse не логинится. Лечение: `localStorage.removeItem("conversejs-session-jid")` + или вход в инкогнито. На новом JID `admin@nixg.ru` работает. +2. **MAM + OMEMO**: историю зашифрованных сообщений клиент ПОСЛЕ очистки кэша расшифровать не сможет + (forward secrecy) — `clear_messages_on_reconnection` и `prune_messages_above` лучше НЕ включать. +3. **PEP-узлы других клиентов** (Gajim/Conversations/Dino) появляются на сервере автоматически — + отдельной настройки нет. Devicelist пополняется при каждом логине нового устройства. +4. **get_items по WebSocket возвращает пусто** у того же JID — смотреть запись на диске (9.5). + +## 10. Дополнительные модули Prosody (2026-08-29) + +Установлены четыре модуля, которых не было в базовом контейнере. + +### 10.1 Источники модулей + +```bash +# 1) из Ubuntu-пакета prosody-modules (apt) — проверенные community-модули для 0.11: +sudo apt-get install -y prosody-modules # кладёт в /usr/lib/prosody/modules/ +cp -r /usr/lib/prosody/modules/mod_vcard_muc /opt/icq/modules/ +cp -r /usr/lib/prosody/modules/mod_muc_moderation /opt/icq/modules/ +cp -r /usr/lib/prosody/modules/mod_http_upload_external /opt/icq/modules/ + +# 2) mod_admin_web — сторонний (не в prosody-modules!), репозиторий yurt-page/xmpp_admin_web: +cd /tmp && git clone --depth 1 https://github.com/yurt-page/xmpp_admin_web.git +cp /tmp/xmpp_admin_web/mod_admin_web.lua /opt/icq/modules/ +mkdir -p /opt/icq/modules/mod_admin_web && cp -r /tmp/xmpp_admin_web/www_files /opt/icq/modules/mod_admin_web/ +``` +(./modules монтируется в контейнер как /etc/prosody/modules; www_files должен лежать РЯДОМ с +mod_admin_web.lua — модуль раздаёт их через `module:get_directory()/www_files`.) + +### 10.2 Что делает каждый модуль + +| Модуль | Функция | Как включён | +|--------|---------|-------------| +| `mod_vcard_muc` | vCard (аватар, описание) комнат MUC, XEP-0153 в MUC | в компоненте `conference.nixg.ru` | +| `mod_muc_moderation` | Модерация MUC (XEP-0425): бан/кик/смена темы через ad-hoc | в компоненте `conference.nixg.ru` | +| `mod_http_upload_external` | HTTP Upload с ВНЕШНИМ хранилищем (XEP-0363, отдельный сервис) | НЕ включён (см. ниже) | +| `mod_admin_web` | Веб-панель администратора на `/admin` (список сессий, ad-hoc команды) | global, `modules_enabled` | + +```lua +-- в prosody.cfg.lua +modules_enabled = { ..., "admin_web", "bosh", ... } -- admin_web требует bosh + +Component "conference.nixg.ru" "muc" + ... + modules = { "vcard_muc", "muc_moderation" } +``` + +### 10.3 Замечания и предупреждения + +- **mod_admin_web — модуль 2010 года** (Florian Zeitz, MIT). Использует устаревшие API + (`module.add_host`, `service[host]:add_subscription`, `module:set_global()`, `prosody.hosts`). + Синтаксис под 0.11 валиден (`luac5.1 -p` проходит), но ПОЛНАЯ загрузка не проверялась — + если при рестарте Prosody падает/модуль ругается в логах — закомментировать `"admin_web"` + в modules_enabled; потеряем только панель. +- **Доступ к /admin**: панель раздаётся на HTTP-порту Prosody **5280** (внутри Docker). + Наружу 5280 не проброшен (всё через Caddy 443). Для внешнего доступа — добавить в Caddy + на vps02 блок `admin.nixg.ru { reverse_proxy 10.8.0.2:5280 }` или открывать по WG. +- **mod_http_upload_external НЕ включён**: это альтернатива работающему `mod_http_upload` + (классический, с локальным хранилищем, см. раздел 7). External требует отдельного + HTTP-сервиса для файлов (например, minio/nginx с подписанными URL). Включить при необходимости: + `Component "upload-ext.nixg.ru" "http_upload_external"` + `http_upload_external_base_url`. +- **Порядок включения**: после правок конфига — `docker exec icq-prosody prosodyctl check config` + → `docker compose restart prosody` → `docker logs icq-prosody --tail 100`. + +## 11. Полезные команды ```bash cd /opt/icq docker compose ps @@ -316,8 +478,8 @@ python3 -c "import socket,ssl; ..." # см. раздел 8 curl -sS https://upload.nixg.ru/upload ``` -## 10. TODO (следующие шаги) -- [ ] OMEMO (сквозное шифрование, XEP-0384) — клиенты Converse уже умеют (libomemo в dist); проверить на сервере. +## 12. TODO (следующие шаги) +- [x] ~~OMEMO (XEP-0384)~~ — ВЫПОЛНЕНО: раздел 9; скрипт scripts/omemo_check.py. - [ ] Бот-книгоискатель (slixmpp + OPDS) — Этап 3 PRD. venv уже создан (/opt/icq/.venv: aiohttp, slixmpp). - [ ] Мосты mautrix-telegram / mautrix-whatsapp — Этап 4 (нужен Prosody mod_component / external component). - [ ] Push-уведомления APNs/FCM (публичный push-шлюз для Prosody). diff --git a/backup.sh b/backup.sh new file mode 100755 index 0000000..b542e93 --- /dev/null +++ b/backup.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Скрипт резервного копирования ICQ XMPP (Prosody + webchat) с отправкой на Яндекс.Диск +# По образцу /opt/netbox/backup.sh + +PROJECT_DIR="/opt/icq" +BACKUP_DIR="${PROJECT_DIR}/backups" +YADISK_MOUNT="/mnt/yandex-disk" +YADISK_TARGET="${YADISK_MOUNT}/backup/icq-backups" +DATE=$(date +%Y%m%d_%H%M%S) + +# Папки/файлы проекта, которые бэкапим (из /opt/icq) +INCLUDE="data config certs modules webchat docker-compose.yml .env README.md STATUS.md PRD.md MIGRATION.md WALKTHROUGH.md" + +mkdir -p ${BACKUP_DIR} + +echo "🔄 [$(date)] Начинаем резервное копирование ICQ XMPP..." + +# Проверяем, примонтирован ли Яндекс.Диск +if ! mountpoint -q ${YADISK_MOUNT}; then + echo " ❌ ОШИБКА: Яндекс.Диск не примонтирован!" + exit 1 +fi + +# Создаём папку для бэкапов на Яндекс.Диске (если её нет) +mkdir -p ${YADISK_TARGET} 2>/dev/null + +# 1. Бэкап проекта одним архивом (данные Prosody + конфиги + серты + модули + веб-клиент + доки) +ARCHIVE="${BACKUP_DIR}/icq_${DATE}.tar.gz" +echo " → Архив проекта..." +tar czf ${ARCHIVE} -C ${PROJECT_DIR} ${INCLUDE} 2>/dev/null + +if [ -s "${ARCHIVE}" ]; then + echo " ✅ Архив: $(du -h ${ARCHIVE} | cut -f1)" + echo " ☁️ Копирование на Яндекс.Диск..." + cp ${ARCHIVE} ${YADISK_TARGET}/ + echo " ✅ скопирован на ЯД (${YADISK_TARGET}/)" +else + echo " ❌ Ошибка создания архива!" +fi + +# 2. Очистка старых бэкапов +# Локально: оставляем 7 дней +find ${BACKUP_DIR} -type f -name "icq_*" -mtime +7 -delete 2>/dev/null + +# На Яндекс.Диске: оставляем 30 дней +find ${YADISK_TARGET} -type f -name "icq_*" -mtime +30 -delete 2>/dev/null + +echo "✅ [$(date)] Резервное копирование завершено!" + +# Показываем список созданных бэкапов +echo "" +echo "📊 Созданные бэкапы:" +ls -lh ${BACKUP_DIR}/icq_${DATE}.* 2>/dev/null || echo " (нет файлов)" diff --git a/config/prosody.cfg.lua b/config/prosody.cfg.lua index ea9c2da..32c6d8c 100644 --- a/config/prosody.cfg.lua +++ b/config/prosody.cfg.lua @@ -38,6 +38,8 @@ modules_enabled = { "register"; -- In-band registration (для первого пользователя) "adhoc"; -- ad-hoc команды "admin_adhoc"; -- админ-команды + "admin_web"; -- веб-панель /admin (yurt-page; глобальный, требует bosh+admin_adhoc+http_files) + "bosh"; -- BOSH (HTTP long-polling, для mod_admin_web) -- Messaging / Chat "csi"; -- Client State Indication (экономия батареи) @@ -103,6 +105,8 @@ Component "conference.nixg.ru" "muc" name = "ICQ Family Chat" restrict_room_creation = false -- все могут создавать комнаты max_history_messages = 200 + -- Доп. модули MUC: vcard_muc (аватары комнат, XEP-0153 в MUC), muc_moderation (XEP-0425 модерация) + modules = { "vcard_muc", "muc_moderation" } -- HTTP Upload (XEP-0363) — mod_http_upload (community-модуль, установлен из apt prosody-modules, -- скопирован в ./modules/mod_http_upload/. Работает как Component (НЕ в modules_enabled). diff --git a/modules/mod_admin_web.lua b/modules/mod_admin_web.lua new file mode 100644 index 0000000..341129d --- /dev/null +++ b/modules/mod_admin_web.lua @@ -0,0 +1,343 @@ +-- Copyright (C) 2010 Florian Zeitz +-- +-- This file is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +-- +-- +-- +-- + +-- +-- +-- / +-- +-- +-- / +-- + +local st = require "util.stanza"; +local uuid_generate = require "util.uuid".generate; +local is_admin = require "core.usermanager".is_admin; +local pubsub = require "util.pubsub"; +local jid_bare = require "util.jid".bare; + +local hosts = prosody.hosts; +local incoming_s2s = prosody.incoming_s2s; + +module:set_global(); + +local service = {}; + +local xmlns_adminsub = "http://prosody.im/adminsub"; +local xmlns_c2s_session = "http://prosody.im/streams/c2s"; +local xmlns_s2s_session = "http://prosody.im/streams/s2s"; + +local idmap = {}; + +local function add_client(session, host) + local name = session.full_jid; + local id = idmap[name]; + if not id then + id = uuid_generate(); + idmap[name] = id; + end + local item = st.stanza("item", { id = id }):tag("session", {xmlns = xmlns_c2s_session, jid = name}):up(); + if session.secure then + local encrypted = item:tag("encrypted"); + local sock = session.conn and session.conn.socket and session.conn:socket() + local info = sock and sock.info and sock:info(); + for k, v in pairs(info or {}) do + encrypted:tag("info", { name = k }):text(tostring(v)):up(); + end + end + if session.compressed then + item:tag("compressed"):up(); + end + service[host]:publish(xmlns_c2s_session, host, id, item); + module:log("debug", "Added client %s", name); +end + +local function del_client(session, host) + local name = session.full_jid; + local id = idmap[name]; + if id then + local notifier = st.stanza("retract", { id = id }); + service[host]:retract(xmlns_c2s_session, host, id, notifier); + end +end + +local function add_host(session, type, host) + local name = (type == "out" and session.to_host) or (type == "in" and session.from_host); + local id = idmap[name.."_"..type]; + if not id then + id = uuid_generate(); + idmap[name.."_"..type] = id; + end + local item = st.stanza("item", { id = id }):tag("session", {xmlns = xmlns_s2s_session, jid = name}) + :tag(type):up(); + if session.secure then + local encrypted = item:tag("encrypted"); + + local sock = session.conn and session.conn.socket and session.conn:socket() + local info = sock and sock.info and sock:info(); + for k, v in pairs(info or {}) do + encrypted:tag("info", { name = k }):text(tostring(v)):up(); + end + + if session.cert_identity_status == "valid" then + encrypted:tag("valid"); + else + encrypted:tag("invalid"); + end + end + if session.compressed then + item:tag("compressed"):up(); + end + service[host]:publish(xmlns_s2s_session, host, id, item); + module:log("debug", "Added host %s s2s%s", name, type); +end + +local function del_host(session, type, host) + local name = (type == "out" and session.to_host) or (type == "in" and session.from_host); + local id = idmap[name.."_"..type]; + if id then + local notifier = st.stanza("retract", { id = id }); + service[host]:retract(xmlns_s2s_session, host, id, notifier); + end +end + +local function get_affiliation(jid, host) + local bare_jid = jid_bare(jid); + if is_admin(bare_jid, host) then + return "member"; + else + return "none"; + end +end + +function module.add_host(module) + -- Dependencies + module:depends("bosh"); + module:depends("admin_adhoc"); + module:depends("http"); + + local serve; + if not pcall(function () + local http_files = require "net.http.files"; + serve = http_files.serve; + end) then + serve = module:depends"http_files".serve; + end + local serve_file = serve { + path = module:get_directory() .. "/www_files"; + }; + + -- Setup HTTP server + module:provides("http", { + title = "Admin Interface"; + name = "admin"; + route = { + ["GET"] = function(event) + event.response.headers.location = event.request.path .. "/"; + return 301; + end; + ["GET /*"] = serve_file; + } + }); + + -- Setup adminsub service + local function simple_broadcast(kind, node, jids, item) + if item then + item = st.clone(item); + item.attr.xmlns = nil; -- Clear the pubsub namespace + end + local message = st.message({ from = module.host, type = "headline" }) + :tag("event", { xmlns = xmlns_adminsub .. "#event" }) + :tag(kind, { node = node }) + :add_child(item); + for jid in pairs(jids) do + module:log("debug", "Sending notification to %s", jid); + message.attr.to = jid; + module:send(message); + end + end + + service[module.host] = pubsub.new({ + broadcaster = simple_broadcast; + normalize_jid = jid_bare; + get_affiliation = function(jid) return get_affiliation(jid, module.host) end; + capabilities = { + member = { + create = false; + publish = false; + retract = false; + get_nodes = true; + + subscribe = true; + unsubscribe = true; + get_subscription = true; + get_subscriptions = true; + get_items = true; + + subscribe_other = false; + unsubscribe_other = false; + get_subscription_other = false; + get_subscriptions_other = false; + + be_subscribed = true; + be_unsubscribed = true; + + set_affiliation = false; + }; + + owner = { + create = true; + publish = true; + retract = true; + get_nodes = true; + + subscribe = true; + unsubscribe = true; + get_subscription = true; + get_subscriptions = true; + get_items = true; + + subscribe_other = true; + unsubscribe_other = true; + get_subscription_other = true; + get_subscriptions_other = true; + + be_subscribed = true; + be_unsubscribed = true; + + set_affiliation = true; + }; + }; + }); + + -- Create node for s2s sessions + local ok, err = service[module.host]:create(xmlns_s2s_session, true); + if not ok then + module:log("warn", "Could not create node %s: %s", xmlns_s2s_session, err); + else + service[module.host]:set_affiliation(xmlns_s2s_session, true, module.host, "owner") + end + + -- Add outgoing s2s sessions + for _, session in pairs(hosts[module.host].s2sout) do + if session.type ~= "s2sout_unauthed" then + add_host(session, "out", module.host); + end + end + + -- Add incoming s2s sessions + for session in pairs(incoming_s2s) do + if session.to_host == module.host then + add_host(session, "in", module.host); + end + end + + -- Create node for c2s sessions + ok, err = service[module.host]:create(xmlns_c2s_session, true); + if not ok then + module:log("warn", "Could not create node %s: %s", xmlns_c2s_session, tostring(err)); + else + service[module.host]:set_affiliation(xmlns_c2s_session, true, module.host, "owner") + end + + -- Add c2s sessions + for _, user in pairs(hosts[module.host].sessions or {}) do + for _, session in pairs(user.sessions or {}) do + add_client(session, module.host); + end + end + + -- Register adminsub handler + module:hook("iq/host/http://prosody.im/adminsub:adminsub", function(event) + -- luacheck: ignore 431/ok + local origin, stanza = event.origin, event.stanza; + local adminsub = stanza.tags[1]; + local action = adminsub.tags[1]; + local reply; + if action.name == "subscribe" then + local ok, ret = service[module.host]:add_subscription(action.attr.node, stanza.attr.from, stanza.attr.from); + if ok then + reply = st.reply(stanza) + :tag("adminsub", { xmlns = xmlns_adminsub }); + else + reply = st.error_reply(stanza, "cancel", ret); + end + elseif action.name == "unsubscribe" then + local ok, ret = service[module.host]:remove_subscription(action.attr.node, stanza.attr.from, stanza.attr.from); + if ok then + reply = st.reply(stanza) + :tag("adminsub", { xmlns = xmlns_adminsub }); + else + reply = st.error_reply(stanza, "cancel", ret); + end + elseif action.name == "items" then + local node = action.attr.node; + local ok, ret = service[module.host]:get_items(node, stanza.attr.from); + if not ok then + origin.send(st.error_reply(stanza, "cancel", ret)); + return true; + end + + local data = st.stanza("items", { node = node }); + for _, entry in pairs(ret) do + data:add_child(entry); + end + if data then + reply = st.reply(stanza) + :tag("adminsub", { xmlns = xmlns_adminsub }) + :add_child(data); + else + reply = st.error_reply(stanza, "cancel", "item-not-found"); + end + elseif action.name == "adminfor" then + local data = st.stanza("adminfor"); + for host_name in pairs(hosts) do + if is_admin(stanza.attr.from, host_name) then + data:tag("item"):text(host_name):up(); + end + end + reply = st.reply(stanza) + :tag("adminsub", { xmlns = xmlns_adminsub }) + :add_child(data); + else + reply = st.error_reply(stanza, "feature-not-implemented"); + end + origin.send(reply); + return true; + end); + + -- Add/remove c2s sessions + module:hook("resource-bind", function(event) + add_client(event.session, module.host); + end); + + module:hook("resource-unbind", function(event) + del_client(event.session, module.host); + service[module.host]:remove_subscription(xmlns_c2s_session, module.host, event.session.full_jid); + service[module.host]:remove_subscription(xmlns_s2s_session, module.host, event.session.full_jid); + end); + + -- Add/remove s2s sessions + module:hook("s2sout-established", function(event) + add_host(event.session, "out", module.host); + end); + + module:hook("s2sin-established", function(event) + add_host(event.session, "in", module.host); + end); + + module:hook("s2sout-destroyed", function(event) + del_host(event.session, "out", module.host); + end); + + module:hook("s2sin-destroyed", function(event) + del_host(event.session, "in", module.host); + end); +end diff --git a/modules/mod_admin_web/www_files/css/style.css b/modules/mod_admin_web/www_files/css/style.css new file mode 100644 index 0000000..20ed2e5 --- /dev/null +++ b/modules/mod_admin_web/www_files/css/style.css @@ -0,0 +1,114 @@ +body { + margin: 0 +} + +a { + color: #0000FF +} + +#adhocCommands > ul { + margin: 0 +} + +.btn { + margin-right: 0.3em +} + +.btn:last-child { + margin-right: 0 +} + +#log_container { + clear: both; + display: none +} + +#adhocCommands { + border-right: solid 1px +} + +#adhocCommands li { + list-style: inside +} + +#login { + float: left; + margin: 1em 2em 0 1em; + padding-right: 1em; + border: solid 1px; + background: #eef0f2; + color: #000000 +} + +#main { + display: none; + margin: 1em +} + +#main p { + margin: 0 +} + +#top { + clear: both; + width: 100%; + padding: 0; +} + +@media screen and (min-width: 757px) { + #header { + background: url(../images/blue_orange.png) repeat-x + } +} + +#header img { + max-width: 100%; + height: auto +} + +#menu { + display: none; + color: #454748; + font-size: 1.1em; + background: #eef0f2; + width: 100%; +} + +#menu ul { + display: inline; + list-style-type: none; + margin: 0; + padding: 0.5em 0 +} + +#menu li { + display: inline; + padding: 0 0.5em +} + +#menu a { + color: #454748; + text-decoration: none +} + +#menu li a:hover { + color: #6197DF; + text-decoration: underline +} + +#selector { + display: inline-block +} + +#s2sList h2, #c2sList h2 { + color: #4b8ade; + margin: 0 +} + +#s2sList li, #c2sList li { + cursor: pointer +} + +#host { + margin: 0.25em; +} diff --git a/modules/mod_admin_web/www_files/images/blue_orange.png b/modules/mod_admin_web/www_files/images/blue_orange.png new file mode 100644 index 0000000..88d87d6 Binary files /dev/null and b/modules/mod_admin_web/www_files/images/blue_orange.png differ diff --git a/modules/mod_admin_web/www_files/images/compressed.png b/modules/mod_admin_web/www_files/images/compressed.png new file mode 100644 index 0000000..dc76287 Binary files /dev/null and b/modules/mod_admin_web/www_files/images/compressed.png differ diff --git a/modules/mod_admin_web/www_files/images/encrypted.png b/modules/mod_admin_web/www_files/images/encrypted.png new file mode 100644 index 0000000..5f3cc6c Binary files /dev/null and b/modules/mod_admin_web/www_files/images/encrypted.png differ diff --git a/modules/mod_admin_web/www_files/images/prosody.png b/modules/mod_admin_web/www_files/images/prosody.png new file mode 100644 index 0000000..bb9ea77 Binary files /dev/null and b/modules/mod_admin_web/www_files/images/prosody.png differ diff --git a/modules/mod_admin_web/www_files/images/secure.png b/modules/mod_admin_web/www_files/images/secure.png new file mode 100644 index 0000000..89ddaff Binary files /dev/null and b/modules/mod_admin_web/www_files/images/secure.png differ diff --git a/modules/mod_admin_web/www_files/index.html b/modules/mod_admin_web/www_files/index.html new file mode 100644 index 0000000..aa17599 --- /dev/null +++ b/modules/mod_admin_web/www_files/index.html @@ -0,0 +1,90 @@ + + + + +Prosody Webadmin + + + + + + + + + + +
+ + +
+ +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+

Incoming S2S connections:

+
    +
    +
    +

    Outgoing S2S connections:

    +
      +
      +
      +
      +
      +
      +
      +

      Client connections:

      +
        +
        +
        +
        +
        + + + + diff --git a/modules/mod_admin_web/www_files/js/adhoc.js b/modules/mod_admin_web/www_files/js/adhoc.js new file mode 100644 index 0000000..9144abc --- /dev/null +++ b/modules/mod_admin_web/www_files/js/adhoc.js @@ -0,0 +1,252 @@ +// adhocweb +// Copyright (c) 2010-2013 Florian Zeitz +// This project is MIT licensed. +// http://git.babelmonkeys.de/?p=adhocweb.git;a=blob_plain;f=js/adhoc.js + +Strophe.addNamespace('ADHOC', 'http://jabber.org/protocol/commands') + +function Adhoc (view, readycb) { + this.status = { + sessionid: null, + cmdNode: null, + queryJID: null, + readycb: readycb, + view: view + } +} + +Adhoc.prototype = { + constructor: Adhoc, + + addNote: function (text, type) { + if (!type) { + type = 'info' + } + text = text.replace(/\n/g, '
        ') + $(this.status.view).append(`

        ${text}

        `) + }, + + addForm: function (x) { + let self = this + let form = $(`
        `) + form.submit(function (event) { + self.executeCommand('execute', self.serializeToDataform('form'), + function (e) { self.displayResult(e) }) + event.preventDefault() + }) + let fieldset = $('
        ') + form.append(fieldset) + $(x).find('title').each(function () { $('').text($(this).text()).appendTo(fieldset) }) + $(x).find('instructions').each(function () { $('

        ').text($(this).text()).appendTo(fieldset) }) + $(x).find('field').each(function () { + let clearfix = $(`

        `) + let item = self.buildHTMLField(this) + let label = $(this).attr('label') + if (label) { + $('