Initial commit: Hermes skill xmpp-server-prosody

This commit is contained in:
estorozhenko
2026-09-06 13:51:11 +00:00
commit 3da6c8709b
28 changed files with 2016 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# Backup do Яндекс.Диска (ICQ project + reusable pattern)
Установлено 2026-08-29. Компактный self-hosted проект бэкапится одним tar по образцу соседних проектов (netbox, gitea) на той же машине.
## Файлы и cron
- Скрипт: `/opt/icq/backup.sh` (chmod 755, закоммичен в git проекта).
- Cron (root crontab, добавлен рядом с соседями, чтобы не конфликтовать по ЯД/нагрузке):
- `0 2 * * *` netbox
- `30 2 * * *` gitea
- `45 2 * * * /opt/icq/backup.sh >> /var/log/icq-backup.log 2>&1`
- Лог: `/var/log/icq-backup.log` (появляется при первом срабатывании cron).
## Что бэкапится (INCLUDE в скрипте)
`data config certs modules webchat docker-compose.yml .env README.md STATUS.md PRD.md MIGRATION.md WALKTHROUGH.md`
- `data/` — самое ценное: аккаунты (`accounts/admin.dat`), PEP/OMEMO-бандлы (`pep_*`, `pep_*omemo*devicelist`), MUC, `http_upload`. Хранится URL-encoded: `data/nixg%2eru/...`.
- tar по живому `data/` безопасен — Prosody хранит всё файлами, останавливать контейнер не нужно.
- Сертификаты (`certs/`) кладутся в архив, но в git НЕ идут (`.gitignore: certs/, data/, backups/, *.crt, *.key`).
## Логика скрипта (по образцу /opt/netbox/backup.sh)
1. `mkdir -p BACKUP_DIR` (`/opt/icq/backups`).
2. `mountpoint -q /mnt/yandex-disk` — если не примонтирован → `exit 1` (ЯД монтируется @reboot через davfs).
3. `mkdir -p /mnt/yandex-disk/backup/icq-backups`.
4. `tar czf icq_$(date +%Y%m%d_%H%M%S).tar.gz -C /opt/icq $INCLUDE`.
5. `cp` архива на ЯД.
6. Ротация: локально 7 дней (`find ... -mtime +7 -delete`), на ЯД 30 дней (`-mtime +30`).
## Проверка
- Размер рабочего бэкапа ~5.5MB (вся data крошечная, ~200K; основной вес — webchat/dist 26MB до gitignore).
- Состав проверить: `tar tzf backups/icq_*.tar.gz | grep -E 'data/nixg%2eru/(accounts|pep)'`.
- curl/TXT не нужен; сразу после создания бэкапа файл лежит и локально, и в `/mnt/yandex-disk/backup/icq-backups/`.
## Git-дисциплина проекта
- Remote: `git@gitverse.ru:kpa39l/icq.git` (master).
- Итог каждой сессии коммитить + пушить сразу (`git push origin master`). Секреты не коммитить: `.env` отсутствует в репо (пароль в защищённом хранилище) — это правильно.
- Тестовые/временные артефакты (например, `backups/icq_test_*.tar.gz`) удалять после проверки — они не должны оставаться.
@@ -0,0 +1,161 @@
# Converse.js v14 blank page: websocket_url fix + auth verification
Follow-up to `webchat-conversejs-and-caddy-trap.md` (same ICQ project, 2026-08-28).
These lessons were confirmed after the initial web client deployment: the page served
HTML/CSS/JS fine but rendered NOTHING.
## ⚠️ ROOT CAUSE #1 (FIXED 2026-08-28): Converse v14 is an ES MODULE, not UMD
The v14 dist bundle (`converse.min.js`) is built as a genuine ESM module:
- it uses `import.meta.url` (webpack auto-publicPath) and ends with `export{c as default}`;
- it lazy-loads chunks via dynamic `import("./" + chunkName)` (e.g. `chunkjs/locales/...`).
Loading it with a classic `<script src="converse.min.js">` throws in Chrome/Firefox:
```
Uncaught SyntaxError: Cannot use 'import.meta' outside a module
```
→ the bundle NEVER executes → `converse` is undefined → blank page, and **no request to
`/xmpp-websocket` ever appears in nginx logs** (the client dies before connecting).
`node --check converse.min.js` passes (valid ES syntax), so syntax-checking gives FALSE
confidence. **Check for `import.meta` / `export{c as default}` instead** — build two
distinct bundles and shipped both:
- `dist/converse.min.js`: ESM entry — load ONLY via `<script type="module">` +
`import converse from '/dist/converse.min.js'` in a second inline module script.
- Serve the FULL dist directory from the release tarball (GitHub
`conversejs/converse` → release `converse.js-14.0.0.tgz` → `package/dist/`), which
contains: `chunkjs/locales/*`, `libomemo.esm.min.js`, `curve25519_compiled.wasm`,
`sounds/`, `webfonts/`, `images/`. Dynamic imports resolve relative to the module
URL, so `/dist/converse.min.js` ⇒ chunks at `/dist/chunkjs/...` automatically.
Minimal working skeleton (see /opt/icq/webchat/index.html for the full version):
```html
<link rel="stylesheet" href="/dist/converse.min.css">
<div id="conversejs"></div>
<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', i18n: 'ru' });
</script>
```
Verification (headless chromium, no extra installs — chromium is in snap on bigbox):
```bash
chromium --headless --no-sandbox --disable-gpu --virtual-time-budget=15000 \
--dump-dom https://chat.nixg.ru/ | grep -c converse-login-form # 1 = UI rendered
# console errors BEFORE the fix:
chromium --headless --no-sandbox --enable-logging=stderr --virtual-time-budget=20000 \
--dump-dom https://chat.nixg.ru/ 2>&1 | grep -iE 'CONSOLE|Uncaught'
```
## ROOT CAUSE #2 (earlier, already fixed): `bosh_service_url` vs `websocket_url`
| Option | What Converse does |
|--------|-------------------|
| `websocket_url: 'wss://host/xmpp-websocket'` | Connects via WebSocket. **Use this.** |
| `bosh_service_url: 'wss://host/xmpp-websocket'` | Treats the URL as BOSH (HTTP long-polling endpoint). With a wss:// URL and no BOSH handler on the server, the connection fails SILENTLY and the UI never renders. |
Evidence from the v14 bundle:
```js
function d1() {
return ("WebSocket" in window || "MozWebSocket" in window)
&& cZ.get("websocket_url") ? cZ.get("websocket_url")
: cZ.get("bosh_service_url") ? cZ.get("bosh_service_url") : "";
}
```
So `websocket_url` takes priority; `bosh_service_url` is only a BOSH fallback. Passing a
wss:// URL via `bosh_service_url` is a guaranteed blank page.
## Locales are embedded in v14
The bundle contains all translations as lazy-loaded webpack chunks
(`./src/i18n/locales/*/LC_MESSAGES/converse.po`). `i18n: 'ru'` needs NO external locale
files — do not go hunting for .po files or a locales_path.
## `assets_path` default is `/dist`
Defaults contain `assets_path: "/dist"`. If you deploy bare files (converse.min.js/css in
the web root) rather than the full dist layout, set `assets_path` explicitly to a path
that exists, or Converse may look for extra assets under /dist and fail. This is the
leading hypothesis when the page is STILL blank after the websocket_url fix.
## Diagnosing a blank page without a browser
Check the nginx (webchat container) access log: **a healthy Converse loads JS/CSS with
200s AND then issues a GET to /xmpp-websocket** (the WS upgrade). If css/js get 200s but
NO /xmpp-websocket request ever appears, the client is failing during
initialize/rendering BEFORE it attempts the socket — i.e. a client-side config/asset
problem, not a server problem. Confirm the server side separately (see below) so you know
the failure is client-side.
```bash
docker logs icq-webchat --since 10m | grep -vE "GET /converse\.min" | tail -20
# look for: GET /xmpp-websocket (or absence of it)
```
## Verify auth end-to-end over WebSocket (slixmpp)
Proves the whole path (browser → Caddy → nginx → Prosody → SASL) works even when the web
client is broken. slixmpp needs a no-verify SSL context for self-signed certs:
```bash
pip install slixmpp --break-system-packages
```
```python
import asyncio, ssl, slixmpp
class Bot(slixmpp.ClientXMPP):
def __init__(self, jid, password):
super().__init__(jid, password)
self.add_event_handler('session_start', self.on_start)
self.add_event_handler('failed_auth', self.on_failed)
self.add_event_handler('disconnected', self.on_disconnect)
async def on_start(self, event):
print("AUTH OK:", self.boundjid)
await self.disconnect()
def on_failed(self, event):
print("AUTH FAIL"); self.disconnect()
def on_disconnect(self, event):
self.stop()
async def main():
bot = Bot('user@chat.nixg.ru', 'PASSWORD')
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
bot.ssl_context = ctx
bot.connect(('wss://chat.nixg.ru/xmpp-websocket',)) # full external path incl. TLS
await asyncio.wait_for(bot.disconnected, timeout=25)
asyncio.run(main())
```
Expect `AUTH OK: user@chat.nixg.ru/...`. Notes:
- Direct 5222 TCP test will ALSO hit `SSLCertVerificationError` on self-signed certs —
the `ssl_context` override fixes both, but prefer the wss:// test (validates Caddy TLS
and the WS proxy too).
- `slixmpp.connect()` has NO `reattempt=` kwarg in current versions — omit it.
- `prosodyctl register user domain pass` inside the container is how you set/reset a
password (idempotent, works even if the account exists).
## CDN-hardening: local assets
cdn.conversejs.org may be unreachable/slow from RF. Download the release once and serve
locally:
```bash
cd /opt/icq/webchat
curl -sL -o converse.min.js https://cdn.conversejs.org/dist/converse.min.js
curl -sL -o converse.min.css https://cdn.conversejs.org/dist/converse.min.css
# NOTE: https://cdn.conversejs.org/css/converse.min.css does NOT exist (404); the css
# lives under /dist/ in v14. Check with `curl -sI` and `head -c 100` — a 1.4KB "css" is a 404.
```
Verify the downloaded JS parses: `node --check converse.min.js` → "JS СИНТАКСИС OK".
(If `node` isn't present, `which node` first; the Hermes sandbox runs it from ~/.local/bin.)
@@ -0,0 +1,71 @@
# Docker IP shift: nginx stale upstream → WS 502 → Converse spinner
Symptom (2026-08-30, chat.nixg.ru): page loads fine, the login form flashes briefly,
then Converse shows only the pulsing white "connecting" circle forever. No config
change was made — the break appeared after container recreation.
## Root cause
`icq-webchat`'s nginx resolves `proxy_pass http://prosody:5280` ONCE at config load and
caches the IP for the life of the process. Docker bridge IPs are assigned at container
creation; after `docker compose up` recreates containers (or adds a service) any
container can move to a different IP. In this incident:
- webchat started 42h ago → cached the OLD mapping (prosody = 172.27.0.2)
- prosody recreated 26h ago, slidgram created 22h ago → IPs shifted
- new mapping: slidgram = 172.27.0.2, webchat = 172.27.0.3, prosody = 172.27.0.4
Every `/xmpp-websocket` request was proxied to Slidge's port 5280 (closed) →
Connection refused → 502 → client never connects → spinner forever.
## Diagnostic trail (fast)
```bash
docker logs icq-webchat --tail 40 | grep -E 'xmpp-websocket|refused'
# → connect() failed (111: Connection refused) while connecting to upstream
# upstream: "http://172.27.0.2:5280/xmpp-websocket" ← STALE IP in the error line
docker compose ps --format '{{.Name}}\t{{.Status}}' # "Up X hours" reveals who was recreated
# get ACTUAL container IPs on the compose network:
docker network inspect icq_default --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
```
Compare the upstream IP in the nginx error with the current IP of `prosody`: mismatch =
stale DNS cache. Note: `docker logs icq-prosody` can show 0 lines because Prosody logs
to the mounted volume — read `/opt/icq/logs/prosody.log` instead (it is the live log,
debug-level, presence/roster traffic from the Telegram bridge is visible there).
## Fix
```bash
cd /opt/icq && docker compose restart webchat # nginx re-resolves "prosody" → new IP
```
Verify: `docker logs icq-webchat --since 2m | grep -cE '502|refused'` → 0. The user must
F5 the tab — an already-open tab stuck on the spinner does NOT auto-reconnect.
## Permanent hardening (avoid recurrence)
In `webchat/nginx.conf`, force per-request DNS via Docker's embedded resolver + a
variable in `proxy_pass` (the documented nginx pattern: a static hostname in proxy_pass
is resolved only at config load; a variable makes nginx consult the resolver per request):
```nginx
location /xmpp-websocket {
resolver 127.0.0.11 valid=30s; # Docker embedded DNS, re-resolve every 30s
set $prosody prosody:5280;
proxy_pass http://$prosody/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;
proxy_send_timeout 3600s;
}
```
## Related
- Blank page (NOTHING renders, ESM `import.meta` SyntaxError) → `conversejs-blank-page-and-auth-testing.md`
- Caddy bind-mount inode trap (edit silently ignored) → `webchat-conversejs-and-caddy-trap.md`
- This is the same class as any nginx-in-Docker stale-upstream issue; the tell is the IP
in the 502 line belonging to a different container than the one named in proxy_pass.
+55
View File
@@ -0,0 +1,55 @@
# HTTP Upload (XEP-0363) on Prosody 0.11 — proven recipe (2026-08-28, ICQ/nixg.ru)
## Get the module — apt beats GitHub/hg
- `prosody/prosody:latest` (0.11.9) ships NO mod_http_upload.
- From some networks GitHub (codeload/raw) returns 404/rate-limit and hg.prosody.im misroutes — do not fight mirrors.
- Reliable source on Ubuntu hosts: `sudo apt-get install -y prosody-modules`
Module lands at `/usr/lib/prosody/modules/mod_http_upload/`. Copy into the compose-mounted dir:
`cp -r /usr/lib/prosody/modules/mod_http_upload ./modules/` (compose mounts `./modules:/etc/prosody/modules`)
## Config — Component, NOT modules_enabled
mod_http_upload registers as a COMPONENT. Adding it to `modules_enabled` errors out.
```lua
Component "upload.nixg.ru" "http_upload"
http_upload_file_size_limit = 10 * 1024 * 1024 -- hard cap = Prosody HTTP parser limit
http_upload_expire_after = 7 * 24 * 60 * 60
http_upload_require_authentication = true
http_upload_path = "/var/lib/prosody/http_upload"
http_external_url = "https://upload.nixg.ru" -- public URL WITHOUT :5281 (Caddy proxies 443 → 5281)
```
- `http_upload_file_size_limit` above 10 MB is SILENTLY capped to 10485760 B with warning "exceeds HTTP parser limit on body size" — set it to 10 MB up front.
## TLS requirement — the gotcha
The module refuses to start unless the HTTP endpoint is TLS:
`Error initializing module 'http_upload': File upload MUST happen with TLS but it isn't enabled`
Fix: enable Prosody's https port with a GLOBAL (not VirtualHost) ssl block:
```lua
https_ports = { 5281 }
https_interfaces = { "0.0.0.0" }
ssl = {
key = "/etc/prosody/certs/nixg.ru.key";
certificate = "/etc/prosody/certs/nixg.ru.crt";
}
```
One global `ssl` block also fixes the stock image's benign "No certificate present for https port 5281" bind error.
## Expose publicly (Caddy on vps02)
DNS: `A upload.nixg.ru → <vps02 public IP>` (user action in the DNS panel).
Caddyfile:
```
upload.nixg.ru {
reverse_proxy 10.8.0.2:5281 {
header_up Host {host}
}
}
```
Validate + reload: `docker exec caddy caddy validate --config /etc/caddy/Caddyfile` then `docker exec caddy caddy reload --config /etc/caddy/Caddyfile`.
## Verify
- Prosody log: `upload.nixg.ru:http_upload info URL: <https://upload.nixg.ru/upload> - Ensure this can be reached by users`
If the line shows `:5281` in the URL, http_external_url did not apply (restart needed).
- From vps02 first: `nc -vz 10.8.0.2 5281` must be open before blaming Caddy.
- End-to-end: upload a file via the web client; files land under `data/http_upload/` (monitored for expiry).
+47
View File
@@ -0,0 +1,47 @@
# Let's Encrypt for Prosody (manual dns-01) — proven recipe (2026-08-28, ICQ/nixg.ru)
Goal: trusted cert for the XMPP domain served on c2s 5222 / s2s 5269. Federation rejects self-signed certs ("bad certificate" in Prosody logs from external servers).
## Why dns-01
- http-01 needs a web root on the XMPP domain — impossible when the apex A-record points at a static landing page (81.177.135.175) instead of your server.
- dns-01 only requires adding a TXT record in the DNS panel. Works for any domain whose DNS you control.
## Prereqs
- `sudo apt-get install -y certbot` (Ubuntu 24.04 → certbot 2.9.0; comes from apt, no snap needed).
- If apt is wedged by a broken package (transitional chromium-browser blocked dpkg in this session), repair first: `sudo dpkg --configure -a` then `sudo apt-get install -f`.
- CAA must permit Let's Encrypt: `dig nixg.ru CAA +short` → `0 issue "letsencrypt.org"`.
## Issue
```bash
sudo certbot certonly --manual --preferred-challenges dns \
-d nixg.ru -d xmpp.nixg.ru \
--email <you>@<mail> --agree-tos --no-eff-email \
--manual-auth-hook /bin/true --manual-cleanup-hook /bin/true
```
Key trick: `--manual-auth-hook /bin/true --manual-cleanup-hook /bin/true` make certbot skip interactive prompts — usable from a non-interactive shell. Run it ONCE: it prints the TXT values, you deploy them in the panel, then run the SAME command again: certbot re-checks the still-deployed TXT records and completes issuance. (Plain `--manual` without the hooks reads stdin and dies with EOFError from a non-interactive terminal.)
## TXT records (one per -d name)
```
_acme-challenge.nixg.ru TXT <value1>
_acme-challenge.xmpp.nixg.ru TXT <value2>
```
- Values differ per cert; certbot prints each under "Please deploy a DNS TXT record under the name: ...".
- Wait ~1–2 min, then verify from OUTSIDE the local resolver cache — both must resolve:
`dig @1.1.1.1 _acme-challenge.nixg.ru TXT +short` and `dig @8.8.8.8 _acme-challenge.nixg.ru TXT +short`
## Install for Prosody
```bash
sudo cp -L /etc/letsencrypt/live/nixg.ru/fullchain.pem certs/nixg.ru.crt
sudo cp -L /etc/letsencrypt/live/nixg.ru/privkey.pem certs/nixg.ru.key
docker compose restart prosody
```
Prosody 0.11 reads `certs/<domain>.crt` / `.key`. Log line to confirm: `<domain>:tls info Certificates loaded`.
## Verify — do NOT trust `openssl -starttls`
`openssl s_client -starttls xmpp` is broken for Prosody 0.11: "no peer certificate available", Cipher NONE even when TLS is fine (false negative). Use `scripts/starttls_probe.py` (see `references/tls-starttls-testing.md`).
Verified-good external result: TLS 1.3 TLS_AES_256_GCM_SHA384, issuer Let's Encrypt, SAN nixg.ru+xmpp.nixg.ru on BOTH 5222 and 5269.
## Renewal — MANUAL and easy to forget
- LE certs live 90 days. With `--manual` dns-01 the certbot systemd timer CANNOT renew (it cannot create TXT records on its own).
- Renewal: run `sudo certbot renew --manual-auth-hook /bin/true --manual-cleanup-hook /bin/true` (or repeat the certonly command) → add the NEW TXT values in the panel → re-run → copy fresh certs to `certs/` → restart prosody.
- Schedule a reminder ~6 weeks before expiry (Hermes cron job works; 2026-10-15 for the 2026-11-26 expiry).
@@ -0,0 +1,74 @@
# Module loading & restart verification (2026-08-29)
How to safely add community/custom modules to Prosody 0.11 (Docker) and
verify a restart actually worked, without misreading stale logs.
## Add a community module (from Ubuntu prosody-modules apt)
```bash
sudo apt-get install -y prosody-modules # → /usr/lib/prosody/modules/
# copy ONLY the wanted module into the project's ./modules (mounted as /etc/prosody/modules)
cp -r /usr/lib/prosody/modules/mod_vcard_muc /opt/icq/modules/
# mod_admin_web is stdlib NOT in prosody-modules → clone repo yurt-page/xmpp_admin_web
```
## Restart sequence — do this in order
```bash
docker exec icq-prosody prosodyctl check config # 1. syntax gate → "All checks passed"
docker compose restart prosody # 2. restart
# 3. verify — see "What to check" below
```
## CRITICAL: `docker logs` may be EMPTY — check the file logs
The prosody.cfg.lua `log` block writes to `/var/log/prosody/prosody.{log,err}`
which is bind-mounted to `./logs/`. So **`docker logs icq-prosody` often shows
nothing** for normal startup. Read the real logs:
```bash
tail -50 /opt/icq/logs/prosody.log # runtime info (ports, modules, auth)
tail -50 /opt/icq/logs/prosody.err # errors / module stack traces
```
## What to check after a restart (confirmed-working evidence)
Scan the file log for the post-boot lines (`Activated service`, `Certificates loaded`):
- `portmanager info Activated service 'c2s' on [0.0.0.0]:5222`
- `s2s` on 5269, `http` on 5280, `https` on 5281
- per-domain `Certificates loaded` (nixg.ru, conference.*, upload.*)
- HTTP Upload module prints `URL: <https://.../upload>` + `Storage path`
- clients authenticate (`Authenticated as admin@nixg.ru`), s2s federation streams open
Quick HTTP probes (no auth needed):
```bash
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:5280/http-bind # BOSH → 200
curl -sk -o /dev/null -w '%{http_code}\n' --resolve upload.nixg.ru:5281:127.0.0.1 https://upload.nixg.ru:5281/ # 404 on root is OK
```
## Pitfall: stale errors in prosody.err will mislead you
`/opt/icq/logs/prosody.err` is append-only and can hold OLD errors from earlier
boots (e.g. `http_upload MUST happen with TLS` from BEFORE the LE cert was wired,
or `Failed to open server port 5222` from a double-start). When investigating a
restart, only trust entries dated AFTER your restart timestamp:
```bash
awk '/Aug 29 08:07/,0' /opt/icq/logs/prosody.err | tail -30
```
No entries after the restart timestamp ⇒ the boot was clean.
## FACTS about specific modules (verified by restart 2026-08-29)
- **`mod_admin_web` is NOT a web admin panel.** Despite the name, the
yurt-page/xmpp_admin_web `mod_admin_web.lua` is an XMPP ad-hoc admin module
(adminsub, per-session admin commands). It serves **no HTTP page**:
`GET /admin` on :5280 → **404**. Prosody 0.11 has no built-in web admin UI
(people bolt on separate UI projects). Do not promise a "/admin web panel".
- `mod_bosh` produces a harmless `mod_bosh warn Unable to associate request with
a session` when you curl the empty `POST /http-bind` endpoint — not a fault.
- `mod_vcard_muc` (XEP-0153 avatars in MUC) and `mod_muc_moderation` load **on
the MUC Component**, via `Component ... modules = { "vcard_muc", "muc_moderation" }`,
not in global `modules_enabled`. They load cleanly under 0.11.
- `mod_http_upload_external` is the external-storage variant of HTTP Upload;
kept installed but usually left disabled when classic `mod_http_upload` works.
+71
View File
@@ -0,0 +1,71 @@
# OMEMO (XEP-0384) — проверка и публикация ключей (проверено 2026-08-28, nixg.ru)
## Главное: серверный модуль НЕ нужен
OMEMO полностью работает на клиентах. Prosody хранит ключи/девайс-листы через
PEP (`pep` в modules_enabled — уже включён). Если PEP включён — OMEMO работает
для любого клиента (Gajim, Conversations, Dino, Converse). Серверного
`mod_omemo` в prosody-modules НЕТ и он не нужен (это норма, не потеря).
## Converse v14 (веб-чат)
- OMEMO встроен: в dist лежат `libomemo.esm.min.js` + `curve25519_compiled.wasm`
(проверить: `grep -c omemo converse.min.js` > 0, есть `eu.siacs.conversations.axolotl`).
- ЕДИНСТВЕННАЯ опция в initialize(): `omemo_default: true` (default false) —
«шифровать по умолчанию, когда контакт поддерживает». Опции `allow_omemo`
в v14 нет (всегда доступен).
- При логине с omemo_default:true Converse сам генерит device id и публикует
devicelist + bundle в PEP. Проверять НЕ через UI — смотреть на диск.
## Авторитетная проверка — файлы PEP на диске (host bigbox)
Каталог данных: `data/` из docker-compose (в контейнере `/var/lib/prosody`),
имена узлов URL-encoded:
- devicelist: `data/<domain-encoded>/pep_eu%2esiacs%2econversations%2eaxolotl%2edevicelist/admin.list`
→ grep `["id"] = "N"` — все устройства пользователя (например 4040, 14035).
- bundle: `data/<domain>/pep_eu%2esiacs%2econversations%2eaxolotl%2ebundles%3a<deviceid>/admin.list`
→ должен содержать `identityKey`, 100× `preKeyPublic` (preKeyId 0..99),
`signedPreKeyPublic` + `signedPreKeySignature`, имя `bundle`.
- <domain-encoded>: nixg.ru → `nixg%2eru` (старый chin: `chat%2enixg%2eru`).
## slixmpp 1.17: publish в PEP-узел (рабочий рецепт)
Подключение по WebSocket, паттерн 1.17 (нет `.process()`, `connect` возвращает Future):
```python
import asyncio, ssl, slixmpp
from slixmpp.xmlstream import ET
ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
bot = slixmpp.ClientXMPP('user@domain', 'pass')
bot.ssl_context = ctx
bot.register_plugin('xep_0060')
bot.add_event_handler('session_start', ...) # в handler: send_presence(); await get_roster()
fut = bot.connect(('wss://xmpp.example.com/xmpp-websocket',))
await asyncio.wait_for(fut, 20); await asyncio.sleep(6) # дать событиям отработать
```
Publish (payload — СЫРОЙ lxml-элемент, не высокоуровневый API!):
```python
lst = ET.Element('{eu.siacs.conversations.axolotl}list')
ET.SubElement(lst, '{eu.siacs.conversations.axolotl}device', {'id': '7777'})
await bot['xep_0060'].publish(jid=JID, node='eu.siacs.conversations.axolotl.devicelist',
id='current', payload=lst)
```
Засады:
- `iq['pubsub']['publish']['item']...` + `ElementBase.append('list', {'xmlns':...})`
падает: `TypeError: ElementBase.append() takes 2 positional arguments but 3 were given`.
- `get_items` может вернуть 0 items даже когда узел НЕ пуст (семантика доступа PEP) —
диск `admin.list` авторитетнее. Проверять publish через чтение файла.
## Проверка веб-клиента headless (camofox)
- `camofox-browser open https://chat...` затем `eval` с `JSON.stringify(...)` —
иначе возвращается только `resultType`, без значения.
- Форма входа: `input[name=jid]`, `input[name=password]`, чекбокс `trusted`;
submit-кнопка `button.btn-primary` (текст «Войти»). Заполнять через
`el.value=...; el.dispatchEvent(new Event('input',{bubbles:true}))`, потом click.
- После входа в DOM: `document.body.innerText` содержит «Я на связи».
В DOM появляется строка «OMEMO» (кнопка в чате).
- Новый device от сессии виден в devicelist admin.list + каталог bundles:<id>.
## Очистка/удаление своего устройства из devicelist
Republish devicelist с оставшимися id (паттерн выше), id='current' — примет,
тестовый id исчезает из admin.list.
## Сопутствующее
- `omemo_default` в доке Converse: https://conversejs.org/docs/configuration/
(redirect с /docs/html/...). Единственная omemo-опция.
- Converse api.omemo недоступен из window.converse — проверять по диску, не по JS API.
+64
View File
@@ -0,0 +1,64 @@
# Prosody 0.12 upgrade path — docker images, what it fixes, risks (2026-08-29)
Status: **0.12.6 is the current stable Prosody release, but there is NO official
docker image tag for it.** Upgrading from 0.11.9 fixes two standing problems on
nixg.ru at once: XEP-0356 privileged-entity for the bridge, and UnifiedPush
(`util.jwt` only exists in 0.12+).
## Docker image situation (verified via registry API + pulls)
| Source | Tag | Version | Verdict |
|---|---|---|---|
| `prosody/prosody` (official) | `latest` | **0.11.9** (stale) | official tags stop at 0.11.x; no 0.12 tag exists |
| `prosody/prosody` (official) | `trunk` | unreleased trunk | unstable, not for prod |
| `prosodyim/prosody` (3rd party) | `0.12` | nightly build 236 (2026-04-29) | nightly — NOT a stable release |
| `prosodyim/prosody` (3rd party) | `13.0` | nightly build 98 (2026-06-07) | nightly of next major — no |
| build-your-own | from `https://prosody.im/downloads/source/prosody-0.12.6.tar.gz` (HTTP 200) | **0.12.6 stable** | the correct path |
So `docker pull prosody/prosody:0.12` → `not found`, `prosodyim/prosody:0.12.6`
→ `not found`, `prosodyim/prosody:0.12` exists but is a **nightly** — do not run
nightlies in prod. Only route to a stable 0.12: build a custom image on top of
`prosody/prosody:0.11.9` compiling/installing the 0.12.6 tarball, and re-test.
## What the 0.11 → 0.12 upgrade FIXES (motivation)
1. **XEP-0356 privileged entity (bridge roster/bookmarks).** `mod_privilege` is
**built into 0.12** (modulemanager has `module:send_iq()`). On 0.11 the
community `mod_privilege.lua` from prosody-modules is **written for 0.12 API**
and crashes on 0.11 (`attempt to call method 'send_iq' (a nil value)`), which
breaks privileged IQ → slidge cannot write bookmarks → falls back to sending
a gateway invite for EVERY group → client shows "миллион запросов на
подключение к чату" (see `slidge-flood-invites-and-roster.md`).
2. **UnifiedPush.** `mod_unified_push` needs `util.jwt`, which only exists in
0.12+. On 0.11 it fails to load (`module 'util.jwt' not found`). 0.12 opens
the UnifiedPush route (or stay on XEP-0357 `mod_push` which works on 0.11).
3. Bookmarks/OMEMO/PEP handling improvements.
## Data compatibility & migration
- Prosody's internal storage (data dir) is compatible 0.11 → 0.12; users,
rosters, MUC config survive. No data migration tool needed for this hop.
- Config format is the same Lua; 0.12 adds options but accepts 0.11 configs
(validate with `prosodyctl check config` after the switch).
- `component_secret` per-Component stanza (0.11 behaviour) still works.
## Upgrade procedure (when approved)
1. Build custom image `prosody-012` FROM `prosody/prosody:0.11.9` (keeps Lua,
dirs, entrypoint) + download `prosody-0.12.6.tar.gz`, `./configure
--prefix=/usr --sysconfdir=/etc/prosody --datadir=/var/lib/prosody`,
`make && make install`.
2. Remove the community `mod_privilege.lua` from `./modules` (built-in in 0.12)
— but keep `privileged_entities` config, it is used the same way.
3. `prosodyctl check config` → restart → grep logs for `Error initializing`.
4. Verify slidge: bookmarks now written via XEP-0356 (no more invite flood),
`docker logs icq-slidgram` shows no `PermissionError`/privilege errors.
5. Optional: add `mod_unified_push` (apt prosody-modules now fine) for
UnifiedPush, or keep XEP-0357 `mod_push`.
6. Rollback: pin image back to `prosody/prosody:0.11.9` (data dir untouched).
## Related
- `xep-capability-matrix-prosody-011.md` — the XEP-0356 row there is 0.11-specific;
on 0.12 use the built-in module instead of the community one.
- `push-notifications.md` — the util.jwt/0.12 dependency chain for UnifiedPush.
+155
View File
@@ -0,0 +1,155 @@
# Prosody 13.0 parallel test stand (icq-prosody-test) — recipe + state
Goal: validate Prosody 13.0 (custom image `gitea.nixg.ru/hermes/icq-prosody:13.0`) in
parallel with the production 0.11.9 container, WITHOUT touching prod. Checked:
authorization (works), mod_privilege (XEP-0356) functional test — **PASSED 2026-08-30**:
external component got privileges and a privileged roster IQ got `type=result`.
## Layout (on bigbox, /opt/icq/test/prosody)
```
/opt/icq/test/prosody/
config/prosody.cfg.lua # test.nixg.ru, ports 15222/15269/15280, component secret
config/certs/ # self-signed test.nixg.ru.{crt,key}
data/ # prosody data (URL-encoded: data/test%2enixg%2eru/accounts/)
logs/prosody.log|err
auth_test.py # slixmpp auth tests (passes)
privilege_test.py # external-component mod_privilege probe (PASSES)
```
## Container
```bash
docker run -d --name icq-prosody-test --restart unless-stopped -h test.nixg.ru \
-v /opt/icq/test/prosody/config:/etc/prosody \
-v /opt/icq/test/prosody/data:/var/lib/prosody \
-v /opt/icq/test/prosody/logs:/var/log/prosody \
-p 15222:15222 -p 15269:15269 -p 15280:15280 \
-p 15347:5347 \
gitea.nixg.ru/hermes/icq-prosody:13.0
```
NOTE: on 13.0 the component listener binds 5347 inside the container (component_ports
removed — see SKILL.md). Publish as 15347:5347.
## Key config: external component (module-less Component) + secrets
```lua
component_secrets = {
["telegram.test.nixg.ru"] = "test-secret-telegram-123",
}
-- 13.0 requires the explicit external-component block to raise the 5347 listener:
-- Component "telegram.test.nixg.ru" -- NO module name => external XEP-0114
-- component_secret = "test-secret-telegram-123";
```
pubsub (13.0): `Component "pubsub.test.nixg.ru" "pubsub"` — NOT a VirtualHost module.
## Auth test (slixmpp — verified WORKING on 13.0)
- slixmpp must skip cert verification on the self-signed stand:
```python
self.ssl_context = ssl.create_default_context()
self.ssl_context.check_hostname = False
self.ssl_context.verify_mode = ssl.CERT_NONE
```
- Force non-standard port the RELIABLE way: `await x.connect('127.0.0.1', 15222)`.
`x.address = ...` is IGNORED (DNS lookup of the JID domain wins; it dialed the
public IP :5222 → connect errors). `custom_address` attribute also did not work in
slixmpp 1.17.0 — the positional args to connect() are the only thing that worked.
- Results: testuser1/testuser2/admin AUTH OK; wrong password → `failed_auth` → "AUTH FAILED".
- Handler registration differs: ComponentXMPP has NO `add_handler`/`make_iq_get(queryns=...)`.
Use `iq = comp.Iq('get', id)` + `iq.append(ET.Element('{jabber:iq:roster}query'))`.
NOTE: `comp.add_event_handler('iq', ...)` does NOT fire for incoming IQ on ComponentXMPP —
register a raw Callback on `{jabber:component:accept}iq` instead (see mod_privilege section below).
## mod_privilege (XEP-0356) status — PASSED
- NOT in Prosody core in 13.0 (stock image has zero privilege files). Community module.
- 13.0 needs the prosody-modules TIP version (promise API) — the old 0.11.9 callback
stub is incompatible (`:next` vs callback). hg.prosody.im/prosody-modules/raw-file/tip/mod_privilege/mod_privilege.lua (~685 lines).
- The custom image embeds it (prosody-docker/modules/mod_privilege.lua in the hermes/icq repo; workflow builds via Gitea Actions).
### Working config (exact, verified)
```lua
-- GLOBAL: component_interfaces MUST be global (above VirtualHost/Component), else listener stays 127.0.0.1
component_interfaces = { "0.0.0.0" }
-- EXTERNAL COMPONENT block — module-less => XEP-0114; raises the 5347 listener
Component "telegram.test.nixg.ru"
component_secret = "test-secret-telegram-123"
modules_enabled = { "privilege" } -- so mod_privilege sees component-authenticated
-- VirtualHost: mod_privilege MUST also be in the host's modules_enabled,
-- and privileged_entities lives HERE (host scope, NOT component scope)
VirtualHost "test.nixg.ru"
modules_enabled = { "privilege", ... }
privileged_entities = {
["telegram.test.nixg.ru"] = {
roster = "both", -- perm access=roster type=both
message = "outgoing",
iq = { { namespace = "http://jabber.org/protocol/pubsub", type = "both" }, ... }
}
}
```
Trigger: `log = { debug = "/var/log/prosody/prosody.log", error = "/var/log/prosody/prosody.err" }`
(or `info = ...`) — then the debug lines prove it end-to-end:
```
test.nixg.ru:privilege debug Entity is privileged
test.nixg.ru:privilege debug Roster get from allowed privileged entity received
```
### Gotchas found on 13.0 (IMPORTANT)
1. **`component_ports` removed entirely** in 13.0 (not a single grep hit in /usr/lib/prosody).
Component listener = hardcoded 5347 (`default_port = 5347` in mod_component.lua). Publish as e.g. 15347:5347.
2. **`component_secrets` alone does NOT activate the listener.** You MUST have the module-less
`Component "jid"` block (mod_component loads only for an actual external-component host).
3. **`component_interfaces` is global-scope only.** Put it at the top of the config, NOT inside
a VirtualHost/Component block, or it is silently ignored (check config complains "Problems found").
4. **`log` block: only ONE `log = {...}` allowed.** A second log= later in the file OVERWRITES the
first (Lua). If first had `info = file` and second `debug = console`, info-file logging silently dies.
Keep one log block in the global section, `info = file, error = file`.
5. **Component session has NO `full_jid`** (mod_component sets only `session.host`). The tip
mod_privilege logs `Entity nil try to get roster without permission` when privileges are missing —
but with the config above privileges ARE granted; "Entity is privileged" debug appears. The
"Entity nil" line is a log cosmetic, not a privileges failure.
6. **mod_privilege must be in modules_enabled BOTH on the Component block AND on the VirtualHost.**
Loaded only globally (outside any host) it won't resolve `privileged_entities` from the VirtualHost.
### slixmpp 1.17 ComponentXMPP — in-band IQ gotcha
ComponentXMPP registers ONLY handshake + presence_probe handlers — **incoming IQ stanzas are NOT
processed** (`add_event_handler('iq', ...)` never fires). The XML arrives fine (visible in
xmlstream DEBUG), but no callback runs. Fix: register your own Callback on the raw IQ path:
```python
from slixmpp.xmlstream.handler import Callback
from slixmpp.xmlstream.matcher import MatchXPath
comp.register_handler(Callback('IQPriv', MatchXPath('{jabber:component:accept}iq'), on_iq))
```
...and note the callback receives the IQ as a RAW XML STRING, not a stanza object — parse with
`ET.fromstring(...)` then `elem.get('type')` / `elem.findall('{jabber:iq:roster}item')`.
### Verified test output (2026-08-30)
```
>>> component connected as telegram.test.nixg.ru
>>> on_iq fired: type=result, id=priv-roster-1
>>> PRIVILEGE OK: mod_privilege ответил result (roster testuser1)
```
(The trailing "TIMEOUT" in the probe is cosmetic — disconnect() races wait_until('disconnected');
the result was already received.)
## Version sanity checks (recap)
- prod = prosody/prosody:latest = 0.11.9 (the buggy mod_privilege era)
- image = hermes/icq-prosody:13.0 (Debian trixie-slim base, apt prosody 13.0, 5 custom modules)
- runner = gitea/runner:3.3.1; job image docker27-bash (docker:27 + bash) — runner shells
run steps via bash; Alpine docker:27 has no bash → exit 127. Use GITHUB_TOKEN for clone,
PKG_TOKEN (write:package) only for registry login.
@@ -0,0 +1,79 @@
# Production migration 0.11.9 → 13.0 (verified 2026-08-30, chat.nixg.ru / /opt/icq)
Live migration of the production XMPP server from Prosody 0.11.9 to 13.0
(image `gitea.nixg.ru/hermes/icq-prosody:13.0`). Complements the parallel-stand
recipe (`prosody-13-parallel-stand.md`) with the PROD-only steps and two
regressions that only surface with real bridges/users.
## Steps
1. **Backup first**: `cd /opt/icq && tar czf backups/pre-migration-13-$(date +%Y%m%d_%H%M%S).tar.gz config data certs modules webchat`
2. **Swap image** in docker-compose.yml: `prosody/prosody:latest` → `gitea.nixg.ru/hermes/icq-prosody:13.0`
(also remove obsolete top-level `version: "3.9"` — Compose v2 warns).
3. `docker compose up -d prosody` then `docker exec icq-prosody prosodyctl check config`.
## Config changes 0.11.9 → 13.0 (prod)
1. `component_ports = { 5347 }` — **REMOVED** in 13.0 (listener hardcoded on 5347; delete the line).
The module-less `Component "telegram.nixg.ru"` block already raises the listener.
2. **`"pubsub"` out of `modules_enabled`** on the VirtualHost → must be a separate
`Component "pubsub.<domain>" "pubsub"`. Without this, startup fails:
`Error initializing module 'pubsub' on '<host>': Pubsub should be loaded as a component`.
3. **HTTP Upload regression — Slidge gets "No upload slot"**:
- Symptom: slidge logs floods of `No upload slot in this IQ: <iq xmlns="jabber:client" id="0" />`
(~180 / 10 min on a busy bridge). Attachments (images/videos) silently fail.
- Cause: the 13.0-era community mod_http_upload only hands out slots to
`origin.type == "c2s"` **or** JIDs in `http_upload_access`. A Slidge external
component requests slots as a component → denied → empty result IQ.
- Fix, inside the upload component block:
```lua
Component "upload.<domain>" "http_upload"
http_upload_access = { "telegram.<domain>" }
```
- After fix: 0 errors. (`docker logs icq-slidgram --since 2m | grep -c "No upload slot"` = 0.)
4. `cross_domain_websocket` is deprecated in 13.0 but STILL read by mod_websocket → keep it.
(New mechanism is `http_cors_override`; no need to migrate yet.)
5. Single global `log` block (13.0 tolerates only one).
## Post-migration verification (no client password needed)
- `prosodyctl check config` → All checks passed.
## WebSocket SASL regression after 13.0: "no-auth-mech" / blank spinner
**Symptom:** Converse.js on https://chat.nixg.ru shows the login form for a split
second, then an infinite white spinner. Browser devtools on the WS frame shows
`<failure><no-auth-mech/></failure>` or the server offers NO SASL mechanisms.
Prosody logs flood every ~1s with:
`warn No stream features to offer on insecure session. Check encryption and security settings.`
**Cause:** TLS is terminated at the edge (Caddy → nginx → Prosody over plain HTTP
:5280), so the WebSocket session arrives at Prosody as *insecure*. In 13.0
mod_websocket only offers SASL on secure sessions; insecure → empty `<stream:features>`
→ client cannot authenticate and reconnects forever. (On 0.11.x this silently worked.)
**Fix:** in the GLOBAL config section (before VirtualHost), set
```lua
consider_websocket_secure = true
```
This is mod_websocket's own option (`module:get_option_boolean("consider_websocket_secure")`,
mod_websocket.lua:35, 286 — `session.secure = consider_websocket_secure or request.secure or session.secure`).
- `trusted_proxies` does NOT fix this — it only affects IP/logging, not session secure-ness.
- Do NOT set `c2s_require_encryption = false` — that would allow plaintext passwords on
the external 5222 port. The webchat path is already TLS all the way to the browser.
**Verify:** after restart, prosody.log shows
`Authenticated as user@nixg.ru [prosody:operator]` for WS logins and the
`No stream features...` warnings stop. Headless check:
`chromium --headless --no-sandbox --disable-gpu --virtual-time-budget=20000 --dump-dom https://chat.nixg.ru/ | grep -c converse-login-form` → 1.
- Component auth: `grep "External component successfully authenticated" prosody.log` (timestamp after restart).
- mod_privilege XEP-0356 live: `grep "privilege" prosody.log | grep "Archiving stanza"` — slidge-carbon-*
stanza entries prove privileged message/roster delivery works on prod.
- WebSocket path: raw handshake to the webchat port → `101 Switching Protocols` confirms nginx → Prosody 13.0.
- SASL logic: slixmpp/raw connect with a WRONG password → `AUTH FAILED` proves the auth chain works.
(Do not connect a second component with the same JID as the live bridge — Prosody logs
`Second component attempted to connect, denying connection`; that is normal, not an error.)
## Gotchas
- `docker exec icq-prosody sh -c 'ss/netstat...'` may show nothing if net-tools absent — rely on
`prosody.log` ("Servers started", "Certificates loaded") and external handshakes instead.
- Test-stand teardown for "stop until next update" (NOT delete):
`docker stop icq-prosody-test && docker update --restart=no icq-prosody-test`.
- Prod was re-verified healthy after: prosody/slidgram/webchat all Up, 0 upload errors.
+47
View File
@@ -0,0 +1,47 @@
# Push notifications (APNs/FCM) — XEP-0357 vs UnifiedPush (2026-08-29)
Status in /opt/icq: **deferred** (no real need). Verified facts + two working paths
for when it becomes needed. The key gotcha: our container is **Prosody 0.11.9**,
and that version pins which push modules can load.
## How XMPP push works (mental model — it is "inverted")
- **XEP-0357 "Push Notifications"** is the standard. The server does NOT send to
APNs/FCM itself. Instead: the mobile client, on going background, registers
"push service + token" with the XMPP server; when an offline message arrives the
server pokes that push service, which then delivers via APNs/FCM.
- Native clients Conversations (Android) and Monal (iOS) use their **own public
push gateways** (conversations.im, push.monal.im). For them you only need the
XEP-0357 modules enabled on the server — you do NOT run your own APNs/FCM bridge.
A self-built APNs bridge is the heavy path (Apple dev account, .p8 token, a daemon
that receives Prosody callbacks) — only worth it for a strict "no third parties" goal.
## Version-compatibility MATRIX (the important part)
| Module / source | Prosody 0.11.9 (our container) | Notes |
|-----------------|-------------------------------|-------|
| `mod_unified_push` (in **apt** prosody-modules) | **WILL NOT LOAD** | requires `util.jwt` = Prosody **0.12+** API. Fails: `modulemanager error: module 'util.jwt' not found`. UnifiedPush ≠ XEP-0357. |
| `mod_push` / `mod_push_offline` / `mod_push_muc` (XEP-0357) | works on 0.11 | **NOT in Debian/Ubuntu apt prosody-modules package.** Get from official repo `https://hg.prosody.im/prosody-modules/` (Mercurial — git clone of `hg.prosody.im` returns 400; there is no official git mirror). Drop into `./modules`, add `"push"; "push_offline";` to `modules_enabled` (+`"push_muc"` for MUC rooms). |
| `mod_unified_push` | works | only after upgrading Prosody to 0.12 (then `util.jwt` exists). |
Bottom line:
- **Roadmap option 1 (recommended, works on 0.11):** XEP-0357 `mod_push` + `mod_push_offline`
(+`mod_push_muc`) from hg.prosody.im → native Conversations/Monal push via their public gateways.
- **Roadmap option 2:** upgrade Prosody to 0.12 → then apt's `mod_unified_push` (UnifiedPush) works.
## Probe I used (and the failure it surfaced)
Enabled `mod_unified_push` (copied from apt into ./modules, added to modules_enabled),
`prosodyctl check config` passed, then `docker compose restart prosody`, then:
```bash
grep -iE 'unified_push|Error initializing|error' /opt/icq/logs/prosody.log /opt/icq/logs/prosody.err
# → modulemanager error: module 'util.jwt' not found
```
Server stayed UP (module failed to load but did not take the whole process down),
so this kind of error is easy to miss in a quick health check — grep the file logs
specifically for `Error initializing module`.
Clean-up after discovering an incompatible module: remove from `modules_enabled`,
`rm -rf ./modules/<name>`, restart, re-grep for `Error initializing` (should be empty).
+37
View File
@@ -0,0 +1,37 @@
# Silent no_agent watchdog cron pattern (cert expiry & similar)
The user does NOT want confirmation/status messages when there is nothing to act on
("лишний шум"). Replace one-shot "remind me on date X" cron prompts with a silent
no_agent watchdog that only speaks at a threshold.
## Pattern (used 2026-08-29 for the nixg.ru LE cert)
- cron job: `no_agent: true`, daily schedule (`0 10 * * *`), deliver to Telegram DM.
- `script` (e.g. `check_icq_cert.sh`): while healthy → print NOTHING (empty stdout).
no_agent contract: empty stdout = silent tick, nothing delivered; non-empty stdout is
delivered verbatim; non-zero exit sends an error alert.
- Only when the condition crosses the threshold (e.g. `days_left <= 45`) print the
actionable instructions (with the exact commands), which then arrive as the message.
```bash
#!/bin/bash
CERT=/opt/icq/certs/nixg.ru.crt
THRESHOLD=${THRESHOLD:-45}
[ -f "$CERT" ] || { echo "⚠️ Отсутствует сертификат $CERT"; exit 0; }
END=$(openssl x509 -enddate -noout -in "$CERT" | cut -d= -f2)
DAYS=$(( ( $(date -d "$END" +%s) - $(date +%s) ) / 86400 ))
[ "$DAYS" -gt "$THRESHOLD" ] && exit 0 # silence while healthy
echo "🔐 Сертификат nixg.ru истекает через ${DAYS} дн. (${END}) — продлить вручную..."
echo "1. sudo certbot renew --manual-auth-hook /bin/true --manual-cleanup-hook /bin/true"
echo "2. Добавить TXT _acme-challenge.nixg.ru / _acme-challenge.xmpp.nixg.ru в панели Jino"
echo "3. dig @1.1.1.1 _acme-challenge.nixg.ru TXT +short"
echo "4. Повторить certbot renew; 5. cp -L полный серт в /opt/icq/certs/; 6. docker compose restart prosody"
```
## Lessons
- cron jobs created from a CLI session have no live delivery channel — set `deliver`
to a gateway platform (e.g. `telegram:<chat_id>`) or they vanish into the log.
- Testing: run the script directly with a forced threshold
(`THRESHOLD=100 ./check_icq_cert.sh`) to see the noise branch; the normal run must
be silent.
- Status reminder cron job (agent-based, deliver origin) = noise generator; the
watchdog shape is what this user wants.
@@ -0,0 +1,64 @@
# Slidgram: env prefix trap + preset api_id/api_hash (2026-08-29)
Session-proven facts about configuring the Slidge/slidgram XMPP↔Telegram bridge
via env vars. Companion to `slidge-registration-and-avatar-gap.md` and
`slidge-telegram-bridge-nixg.md`.
## The SLIDGE__SLIDGRAM_ prefix (DOUBLE underscore)
Slidge plugin config env vars are NOT `SLIDGE_SLIDGRAM_*` — the prefix is
**`SLIDGE__SLIDGRAM_`** (double underscore). Derived in `slidge/main.py:configure()`:
```python
ConfigModule.ENV_VAR_PREFIX = "SLIDGE_" # base (slidge.util.conf)
ConfigModule.ENV_VAR_PREFIX += f"_{config.LEGACY_MODULE.split('.')[-1].upper()}_"
# LEGACY_MODULE = 'slidgram' → "_SLIDGRAM_"
# result: "SLIDGE__SLIDGRAM_"
```
So for a plugin config option `API_ID` (defined in `slidgram/config.py`) the
env var is `SLIDGE__SLIDGRAM_API_ID`. Setting the single-underscore
`SLIDGE_SLIDGRAM_API_ID` is silently ignored → `config.API_ID` stays `None` →
the registration form falls back to asking every user for api_id/api_hash.
## docker-compose (preset app credentials once)
```yaml
slidgram:
environment:
- SLIDGE_JID=telegram.nixg.ru
# ...
- SLIDGE__SLIDGRAM_API_ID=<api_id from my.telegram.org/apps>
- SLIDGE__SLIDGRAM_API_HASH=<api_hash>
```
## Applying env changes — force-recreate, NOT restart
- `docker compose restart` does **NOT** apply new/changed `environment:` entries
— the container keeps the old env (verified: env vars absent after restart).
- Must recreate the container:
`docker compose up -d --force-recreate --no-deps slidgram`
(`--no-deps` avoids touching prosody). Compose v2 warns about the obsolete
`version:` attribute — harmless.
## Verifying the config was read
- The slidge entrypoint (`slidgram` console script → `slidgram:main`) runs
`ConfigModule(plugin_config).set_conf()` which is what reads env into
`slidgram.config`. A throwaway `docker exec ... python -c "from slidgram
import config; print(config.API_ID)"` shows `None` — this is EXPECTED, not a
failure (set_conf never ran in that throwaway process).
- Reliable check — emulate the entrypoint:
```python
from slidgram import config as plugin_config
from slidge.util.conf import ConfigModule
ConfigModule.ENV_VAR_PREFIX = 'SLIDGE__SLIDGRAM_'
ConfigModule(plugin_config).set_conf([])
print(plugin_config.API_ID, plugin_config.API_HASH)
```
- Or trust the live logs: after a clean start, `docker logs icq-slidgram |
grep -iE 'Slidge has successfully started'`. The recurring
`ConnectionTimeoutError ... web.telegram.org/img/logo_share.png` avatar
timeout in those logs is the KNOWN HTTP-gap (slidge's aiohttp client is not
SOCKS5-proxied) and is NOT a sign of breakage — it does not block
registration or messaging.
@@ -0,0 +1,102 @@
# Slidge invite flood, roster loss and group-join "unavailable" — diagnosis playbook
Symptom cluster (seen 2026-08-29 on nixg.ru, estorozhenko@ account):
1. After a server restart the Telegram contacts are GONE from the client roster
and even `telegram.nixg.ru` no longer shows.
2. Client (Pidgin) got "миллион запросов на подключение к чату" (a flood of
group-join invitations), each join failing with «Получатель недоступен» /
recipient-unavailable.
3. Joining `group-<id>@telegram.nixg.ru/<nick>` errors.
Root cause chain (verified in logs + slidge 0.4.2 source):
## Layer 1 — roster is NOT lost on the server
Before assuming data loss, query the roster from a fresh slixmpp client:
```python
import asyncio, slixmpp
async def main():
bot = slixmpp.ClientXMPP('user@dom','pass')
import ssl; ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
bot.ssl_context=ctx
await bot.connect(('localhost',5222))
iq = bot.make_iq_get(); iq['id']='roster3'
# NOTE: roster get must have NO 'to' attribute — sending to the server domain
# (to=nixg.ru) returns service-unavailable!
iq.xml.append(iq.xml.makeelement('{jabber:iq:roster}query',{}))
# ... await result, count items with 'telegram.nixg.ru' in jid
asyncio.run(main())
```
Pitfall: `make_iq_get()` + roster query sent WITH `to=<server domain>` →
`service-unavailable` (false negative, made me suspect mod_roster was broken).
Without `to` it works and returned 1602 telegram contacts. Conclusion: server
side is intact; the CLIENT had dropped the roster view after reconnect. Fix:
disable/re-enable the account in the client (Pidgin) or restart it — client
re-downloads the roster on login.
## Layer 2 — mod_privilege (XEP-0356) is 0.12-only API; crashes on 0.11
`prosody/logs/prosody.err` showed:
`mod_privilege.lua: ... attempt to call method 'send_iq' (a nil value)`.
The prosody-modules `mod_privilege.lua` is written against Prosody **0.12**
(`module:send_iq(wrapped_iq, newsession):next(...)`). On 0.11.9 there is no
`module:send_iq`, so every privileged IQ from the bridge crashes the handler.
Effect on slidge: it uses `xep_0356.send_privileged_iq()` to write the user's
PEP **bookmarks**. With mod_privilege broken the write times out →
`room.add_to_bookmarks()` hits its failure branch and calls
`send_gateway_invite()` for EVERY group (because the bookmark could not be set
automatically). That is the "million join invites" — each invite is
`group-<id>@telegram.nixg.ru`, and trying to join it while the bookmark write
never completed gives recipient-unavailable.
## Layer 3 — slidge first-run sync floods the Telegram API
First login after registering a large account: slidge syncs hundreds of
chats/contacts via MTProto and hits `Flood in get_chat/get_users/
get_chat_member ... sleep for 11-22 seconds` (slidgram `handle_flood`).
This slows joins to ~20+ s and contributes to join timeouts. It is transient —
after the cache fills, floods stop. Not a bug; do not restart the bridge to
"fix" it (each restart re-runs part of the sync and can re-trigger floods, plus
`Client is already terminated` noise on stop).
## Layer 4 — user preference toggle: always_invite_when_adding_bookmarks
`user_account.preferences` in slidge.sqlite had:
`{"sync_avatar": true, "always_invite_when_adding_bookmarks": true, ...}`.
With bookmarks broken (Layer 2) every add triggers an invite because of this
flag. Turning it off stops the invite spam even while mod_privilege is broken:
```python
import sqlite3; con=sqlite3.connect('/var/lib/slidge/slidge.sqlite')
con.execute("UPDATE user_account SET preferences=... " ) # drop the flag; restart icq-slidgram
```
BUT the real fix is Layer 2/0.12 upgrade — the bookmark write then succeeds and
no fallback invites are sent at all.
## Diagnosing orders (do these FIRST)
1. `docker logs icq-slidgram | grep -iE 'Group|MDS|flood|privilege'` — look for
`Flood in get_chat` and `Error while trying to subscribe to the MDS node`.
2. `grep -iE 'mod_privilege|send_iq|attempt to' /opt/icq/logs/prosody.err` —
the 0.12-API crash.
3. Roster reality check via slixmpp (Layer 1) — proves server data intact.
4. `sqlite3` room table: `SELECT * FROM room WHERE legacy_id LIKE '%<id>%'`
— confirmed group-1001698123474 = "Денис Сорокин (channel)", muc_type=CHANNEL,
jid_localpart=group-1001698123474. Room known to the bridge; join failure was
purely the bookmark/privilege chain above.
## Permanent fixes (in priority order)
- **Upgrade Prosody to 0.12** (built-in mod_privilege) — see
`prosody-012-upgrade-path.md`.
- Do NOT hand-patch community mod_privilege to fake `send_iq` on 0.11:
a stub that ACKs without forwarding still leaves bookmarks unwritten
(slidge waits for the forwarded result) → invite flood persists. A stub was
tried and still produced `Error while trying to subscribe to the MDS node`.
- Alternative: give the bridge roster access via older XEP-0356-free methods
(roster push through regular roster set works — slidge `RosterBackend`), so
contacts DO appear; only bookmarks/auto-invites stay broken until 0.12.
@@ -0,0 +1,78 @@
# Slidgram (XMPP↔TG bridge): registration flow & avatar HTTP gap
Companion to `slidge-telegram-bridge-nixg.md` (full bridge setup + SOCKS5).
These are the two remaining user-facing/known-weak items, validated against
slidgram 0.4.2.dev0 code inside the `icq-slidgram` container (2026-08-29)
and the offline docs copy at `/opt/icq/docs/slidgram/`.
## 1. Registration of a Telegram account through the bridge
Two ways (per slidgram docs `user/registration.rst`); both target the component
JID `telegram.nixg.ru` (no @, no local part):
- **Method A — adhoc "Register" command** (Gajim / Movim / Cheogram / Converse
info card → Commands): opens a form.
- **Method B — text message**: send `register` to `telegram.nixg.ru`; bridge
replies with the same form.
Form fields (from `slidgram/gateway.py:validate`):
- `phone` — international format, e.g. `+7...` (validated by `is_valid_phone_number`).
- `api_id`, `api_hash` — user-provided from https://my.telegram.org/apps
(my.telegram.org is plain HTTP, reachable from RF in a normal browser — only
MTProto/telegram-asset traffic needs the tunnel). If the server env has
`API_ID`/`API_HASH` preset (slidgram/config.py), the form omits these fields.
Flow under the hood:
1. `Client.connect()` — if the session is already logged in, aborts early.
2. `send_code(phone)` → stores `phone_code_hash`; user gets an SMS/code on
other TG clients. Timeout to enter it: `REGISTRATION_AUTH_CODE_TIMEOUT`
(default 60 s, config.py).
3. `sign_in(phone, code_hash, code)`; if the account has 2FA, catches
`SessionPasswordNeeded` and asks for the password (`check_password`).
After success: contacts appear in the XMPP roster as puppet JIDs
`123456789@telegram.nixg.ru`; Telegram messages mirror into the XMPP dialog.
All registration traffic runs over Pyrogram MTProto → `SLIDGRAM_PROXY` (SOCKS5) →
works from RF. Check `docker logs icq-slidgram` for register/login/avatar/error.
Constraints / gotchas:
- **One phone number per server**: re-registering the same number →
"Someone is already using this phone number on this server".
- 2FA password required if enabled; code entry is time-limited.
- Converse web: adhoc command lives in the contact's info card; if the roster
isn't visible yet, the gateway JID can be added as a manual contact.
## 2. Avatar download timeout — root cause (confirmed in code)
Symptom: avatars/logo (e.g. `https://web.telegram.org/img/logo_share.png`) time
out from RF; messaging is unaffected.
- `slidge/core/gateway.py:399` creates `self.http = aiohttp.ClientSession()`
with **no proxy**; `slidge/db/avatar.py:112` (`self.http.get(url)`) downloads
URL-based avatars directly → blocked in RF.
- MTProto side IS proxied: `slidgram/gateway.py:validate()` passes
`proxy=_proxy` (built from `SLIDGRAM_PROXY`) to the Pyrogram `Client`.
- Contact photo avatars use Pyrogram `download_media()` via
`slidgram/telegram.py:download_avatar` (`AVATAR_DOWNLOAD_SLEEP=15` between
downloads) — proxied and working. The HTTP gap only hits URL-based avatars and
the component logo.
Fix options (pick one when implementing):
1. `aiohttp.ClientSession(trust_env=True)` + env `HTTP_PROXY/HTTPS_PROXY/ALL_PROXY`.
⚠️ aiohttp supports SOCKS5 only through the extra `aiohttp-socks` package; a
plain HTTP proxy env var works if that proxy is RF-reachable (or points at
the tunnel's HTTP side).
2. nginx reverse-proxy on bigbox rewriting `web.telegram.org` asset URLs through
the tunnel — no code changes in the container.
3. Where the avatar is a Telegram file_id, rely on Pyrogram `download_media()`
(already proxied) and only proxy the remaining external URLs.
## 3. Useful container paths (diagnostics)
- `slidgram/telegram.py` — `Client(TelegramClient)` subclass, `download_avatar`,
`handle_flood` decorator around MTProto calls.
- `slidgram/gateway.py` — `validate()` / `validate_two_factor_code()` registration.
- `slidgram/config.py` — `API_ID`, `API_HASH`, `REGISTRATION_AUTH_CODE_TIMEOUT`,
`GROUP_HISTORY_MAXIMUM_MESSAGES`, `BIG_AVATARS`.
- `slidge/core/gateway.py` — `__set_http()` at :399 (unproxied aiohttp session).
- `slidge/db/avatar.py` — `CachedAvatar.__download` / `url_modified` (HTTP HEAD/GET).
@@ -0,0 +1,43 @@
# Slidge registration — which XMPP clients can talk to a bare-domain service JID
Verified 2026-08-29 on nixg.ru (estorozhenko@nixg.ru registered via Pidgin,
Method B). Complements `slidge-registration-and-avatar-gap.md` §1.
## The problem
Bridge registration is an interaction with the COMPONENT JID `telegram.nixg.ru`
— a bare domain with no local part. Some clients refuse to start chats with
such addresses:
| Client | Bare-domain JID (`telegram.nixg.ru`) | Notes |
|---|---|---|
| Converse.js (web, chat.nixg.ru) | ❌ refuses | "Пожалуйста, введите корректный XMPP-адрес" — requires user@domain to open a chat / add a contact |
| Pidgin | ✅ works | Buddies → New Instant Message → type `telegram.nixg.ru` (no @) → chat opens → type `register` |
| Gajim | ✅ works | Method A (adhoc Register command) or chat to service JID |
| Movim / Cheogram | ✅ works | Method A supported |
| Conversations (Android) | ✅ works | can message service JIDs |
## Why Converse fails
Converse validates chat targets as full JIDs (user@domain). A domain-only JID
fails its address validation on the "new chat / add contact" path, so the
bridge (which lives at the domain) is unreachable from the web client for
registration. This is a client limitation, not a server config problem.
## Working registration recipe (Pidgin)
1. Pidgin: Buddies → New Instant Message (Ctrl+M).
2. Address: `telegram.nixg.ru` (bare domain, no @).
3. Type `register`. Bridge replies with the form.
4. Enter phone (international `+7...`). api_id/api_hash NOT asked when preset
in env (SLIDGE__SLIDGRAM_API_ID/HASH) — the form omits those fields.
5. Enter the code from SMS/TG notification; if 2FA is on, the bridge asks for
the account password.
6. Verify: `docker logs icq-slidgram | grep 'Login success'`, and
`user_account` row in `/var/lib/slidge/slidge.sqlite`.
## Registering a SECOND user
The bridge binds one TG number per XMPP user per server. A second XMPP user
must run the same `register` flow from their own client — the bridge stores
per-user sessions, it is NOT one shared account.
+117
View File
@@ -0,0 +1,117 @@
# XMPP↔Telegram bridge via Slidge/slidgram (nixg.ru) — implemented 2026-08-29
STATUS: **WORKING.** Component `telegram.nixg.ru` authenticates in Prosody,
Slidge starts, Telegram MTProto reachable over SOCKS5. Remaining: XMPP-side
registration of the Telegram account (message `register` to `telegram.nixg.ru`
from an XMPP client as an admin user) + api_id/api_hash. Port 5347 stays
internal to the docker network (NOT mapped to host — correct).
## Architecture (this environment)
```
[XMPP client (Gajim/Dino/Converse)]
│ c2s 5222
▼
[Prosody container icq-prosody] ← component port 5347 (docker-internal)
▲ XEP-0114 external component, JID telegram.nixg.ru
[slidgram container icq-slidgram] (image slidgram-proxy:latest, Pyrogram)
│ MTProto → SOCKS5 172.27.0.1:1080 (docker bridge gateway = host loopback)
▼
[VPS01 SSH tunnel telegram-tunnel.service] → api.telegram.org / MTProto DCs (outside RF)
```
## Key facts (correct the older planning notes)
- **Image**: `codeberg.org/slidge/slidgram` (NOT `ghcr.io/slidge/slidge-telegram`;
ghcr.io times out from RF). Installed inside: **Pyrogram** (2.3.69) + PySocks
(`import socks` — PySocks-1.7.1 is ALREADY in the image; do not pip-install it).
slidgram version 0.4.2.dev0.
- **Prosody config** — secret goes INSIDE the Component stanza (0.11 behaviour):
```lua
component_ports = { 5347 } -- global section
Component "telegram.nixg.ru"
component_secret = "<secret>" -- global component_secrets table is IGNORED in 0.11
modules_enabled = { "disco" }
```
Without this: Prosody log `Component attempted to identify as telegram.nixg.ru,
but component_secret is not set`; slidgram sees `Stream error: not-authorized`
and loops restarting until it authenticates.
## SOCKS5 routing (required from RF — Telegram blocked; direct api.telegram.org times out http=000)
- Host has `telegram-tunnel.service`: SSH `-D 0.0.0.0:1080` → VPS01 (outside RF).
Docker bridge gateway = host loopback, so a container in `icq_default`
(172.27.0.0/16) reaches it at `172.27.0.1:1080`.
- slidgram does NOT expose a proxy option in CLI/help/config. The Pyrogram
`Client` is constructed in slidgram/telegram.py and slidgram/gateway.py without
`proxy=`. Fix: build a custom image and patch both files to read
`SLIDGRAM_PROXY=socks5://host:port` env and pass `proxy=dict(...)` to the Client.
Pyrogram accepts `proxy=dict(scheme="socks5", hostname=..., port=...)`
(pysocks already installed). Custom image lives at /opt/icq/slidgram/
(Dockerfile + patch-telegram.py + patch-gateway.py).
- Verify from inside the container: python `socks` socket to MTProto DC
`149.154.167.51:443` via proxy → OK.
## docker-compose service
```yaml
slidgram:
image: slidgram-proxy:latest
container_name: icq-slidgram
restart: unless-stopped
volumes:
- ./slidgram/data:/var/lib/slidge
environment:
- SLIDGE_JID=telegram.nixg.ru
- SLIDGE_SECRET=<secret> # MUST match Component component_secret
- SLIDGE_SERVER=icq-prosody
- SLIDGE_PORT=5347
- SLIDGE_HOME_DIR=/var/lib/slidge
- SLIDGE_ADMINS=admin@nixg.ru
- SLIDGRAM_PROXY=socks5://172.27.0.1:1080
depends_on:
- prosody
```
Do NOT set `network_mode: bridge` — it isolates the container from `icq_default`
and it can no longer resolve `icq-prosody`. Leave it in the project network.
## Pitfalls hit (all fixed)
- **Volume ownership**: image runs as uid 10000 (slidge). A root-owned bind mount
→ SQLAlchemy `unable to open database file` crash loop. Fix:
`sudo chown -R 10000:10000 <hostdata>`.
- **`rm` in Dockerfile RUN fails** with `Operation not permitted` when the build
runs as an unprivileged user (COPY writes root-owned files the USER can't delete
from /tmp). Leave the patch scripts in place; don't `rm` them.
- **http avatar/logo download** (`https://web.telegram.org/img/logo_share.png`)
times out from RF — slidge's own aiohttp HTTP client does NOT go through the
SOCKS5 proxy (only Pyrogram MTProto does). Non-blocking (avatar fetch), but
means some https fetches to Telegram assets fail; not required for messaging.
## Registration (user side, next step)
- Docs: `user/registration.rst` — two ways: adhoc "Register" command (Gajim/
Movim/Cheogram) or just send `register` as a message to the gateway address.
- Gateways' JID is the component domain `telegram.nixg.ru` (no @).
- Needs Telegram api_id/api_hash; if not preset, the registration form asks for them.
## References / docs saved in-project
- Offline copy of slidge.im/docs/slidgram/main (18 pages + styles) + README at
`/opt/icq/docs/slidgram/`. Prosody config examples are inline in
`main/admin/examples/index.html` (`#prosody-upload` / `#prosody-no-upload`):
they need `mod_privilege` + `privileged_entities` on the VirtualHost for
roster-sync/legacy carbons, and upload as a Component (`http_file_share`).
- STATUS.md (`/opt/icq/STATUS.md`) tracks bridge state; plan file
/opt/hermes/.hermes/plans/2026-08-29_085200-telegram-bridge.md had the original
(now partly outdated) plan.
## Superseded planning notes (kept for history)
- Earlier notes proposed Telethon / ghcr.io/slidge/slidge-telegram / global
`component_secrets` table — all WRONG as implemented. The image ships
Pyrogram, ghcr is blocked from RF, and 0.11 needs per-Component
`component_secret`.
- mautrix-telegram remains the WRONG tool for XMPP (Matrix-only); do not propose.
- Ban risk: user accepted low risk for a single quiet personal account; a
dedicated spare number is the hygiene recommendation.
@@ -0,0 +1,56 @@
# Slidge/Telegram: folders unsupported, channels listable from DB (2026-08-29)
User question context: "как найти каналы на которые я подписан?" / "папки в телеграме
были, как их вытащить?"
## Telegram folders (папки) — slidge does NOT support them
Checked slidge 0.4.2 + slidgram source: there is **no folder support at all, by design**.
Evidence in `slidgram/session.py`, `_on_tg_UpdateDialogPinned`:
```python
if isinstance(update.peer, pyro_raw_types.DialogPeerFolder):
# TODO: investigate what that is
return
```
- The bridge explicitly ignores `DialogPeerFolder` updates. XMPP has no concept of
"chat folders" — the roster is a flat list of contacts/MUCs, so there is nowhere to
put folder structure even if read.
- Pyrogram DOES have `get_folders()` (raw `messages.GetDialogFilters`), but you cannot
run a second Pyrogram client against the bridge's `*.session` file while the bridge is
live — one MTProto session = one active connection; the second client kicks the first
(bridge disconnects). Do NOT spin up a parallel reader.
- Only honest options: (a) one-time read of folders by briefly stopping the bridge
(~30 s) and using the session file — for a report, not for XMPP; (b) client-side
folders in Pidgin (Blist groups) as the only XMPP-faithful analog.
## Finding subscribed channels/groups — query slidge.sqlite `room` table
All Telegram chats the account is in are in the bridge DB (no need to touch Telegram):
```bash
docker exec icq-slidgram sh -c 'python3 -c "
import sqlite3
con = sqlite3.connect(\"/var/lib/slidge/slidge.sqlite\")
con.row_factory = sqlite3.Row
print(\"rooms:\", con.execute(\"SELECT count(*) FROM room\").fetchone()[0])
for r in con.execute(\"SELECT muc_type, count(*) n FROM room GROUP BY muc_type ORDER BY n DESC\"):
print(\" \", r[\"muc_type\"], r[\"n\"])
for r in con.execute(\"SELECT muc_type, name, jid_localpart FROM room ORDER BY muc_type, name\"):
print(\"%s | %s | %s\" % (r[\"muc_type\"], (r[\"name\"] or \"\")[:50], r[\"jid_localpart\"]))
"'
```
Typical profile for a power user: ~131 rooms = 91 CHANNEL + 29 CHANNEL_NON_ANONYMOUS
(public group/forum channels) + 11 GROUP (small private groups). `jid_localpart` is
`group-<tgid>` — the exact MUC address to join from XMPP.
## Pitfall: `docker exec` + heredoc `< 'PY'` silently returns nothing
`docker exec icq-slidgram python3 - <<'PY' ... PY` printed **empty output** (the heredoc
is consumed by the local shell/pty layer, not delivered to the container's stdin).
Working forms: `docker exec ... sh -c 'python3 -c "..."'` (as above), or write the file
inside the container then `docker cp` out / redirect stdout to a host file. Always
redirect output to a host file when >~80 lines — container stdout can be truncated.
+27
View File
@@ -0,0 +1,27 @@
# XMPP↔Telegram bridge via Slidge (Etap 4, nixg.ru) — decision & planning notes
Date: 2026-08-29. State: PLANNED, not yet implemented end-to-end. Verify component
details against slidge docs (docs.slidge.im) and the image's env before deploying.
## Decision recap (user-driven)
- **Matterbridge** (XMPP MUC ↔ Telegram-group mirror, one binary, connects as a regular XMPP client) — proposed and **REJECTED** by the user ("вариант А мне не интересен"). It mirrors rooms/groups only, no contact roster.
- **mautrix-telegram** — **WRONG TOOL for XMPP**: it is a Matrix↔Telegram puppeting bridge requiring a Matrix homeserver (Synapse/Dendrite). It cannot attach to Prosody. Do not propose it again for this project.
- **Slidge** (https://slidge.im) — correct class of tool: an XMPP **transport/gateway** that maps a foreign network's contacts into virtual XMPP JIDs.
## How Slidge attaches (expected shape)
- Implements XEP-0114 (external components). Connects to Prosody's component port, default **5347**.
- Prosody side — GLOBAL section of prosody.cfg.lua (mod_component is bundled in the stock prosody/prosody image; present at /usr/lib/prosody/modules/mod_component.lua):
```lua
component_ports = { 5347 }
component_secrets = { ["telegram.nixg.ru"] = "<random-secret>" }
```
The old commented line `-- Component "telegram.chat.nixg.ru" "xmpp_component"` in /opt/icq/config/prosody.cfg.lua was wrong on two counts: module name `xmpp_component` doesn't exist, and a `Component` stanza is not needed for a self-connecting external component — component_ports/secrets activate it.
- Container: `ghcr.io/slidge/slidge-telegram` (or build from ghcr.io/slidge/slidge + telegram plugin). Must share the prosody docker network (icq_default) to reach `icq-prosody:5347`. Volume for the Telethon session (`./slidge/data`), config via env or slidge.toml.
- Telegram side: login with a **user account via Telethon/MTProto** (NOT a bot). Contact mapping: `+79123456789@telegram.nixg.ru`.
- Onboarding: XMPP user contacts `telegram@telegram.nixg.ru` → Slidge walks through pairing (QR/code) → roster fills with Telegram contacts. Full feature set is best in native clients (Gajim/Dino/Conversations); Converse web works basically (roster) but is limited.
## Pitfalls / risks
- ⚠️ MTProto user login violates Telegram ToS → **account ban risk**. Require a dedicated spare number/account, never the user's main one. State this explicitly to the user before proceeding.
- Port 5347 must stay inside the docker network — do NOT expose via iptables/DNAT or Caddy.
- No end-to-end encryption through the bridge (Slidge may do OMEMO on the XMPP leg; Telegram leg has its own crypto).
- Plan file (full task breakdown): /opt/hermes/.hermes/plans/2026-08-29_085200-telegram-bridge.md
+20
View File
@@ -0,0 +1,20 @@
# Testing STARTTLS on Prosody (c2s 5222 / s2s 5269) — tools that lie, and the python way
## Why naive tools fail
- `openssl s_client -connect host:5222` WITHOUT `-starttls` → "wrong version number". Expected: XMPP starts as plaintext XML, TLS upgrade comes after `<starttls/>`.
- `openssl s_client -starttls xmpp ...` → "no peer certificate available", Cipher NONE. FALSE NEGATIVE: OpenSSL 3.x's xmpp starttls mode does not complete Prosody's handshake. Do not chase this.
- Prosody 0.11 does NOT send the stream header first — it waits for the client's `<stream:stream>`. A bare probe that only reads times out; that is normal, not a hang.
- Raw python socket sending nothing → recv timeout. Normal.
## Correct probe: manual STARTTLS in python
1. send `<stream:stream to='nixg.ru' xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' version='1.0'>` → recv features (starttls, SCRAM-SHA-1)
2. send `<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>` → recv `<proceed/>`
3. `ssl.create_default_context().wrap_socket(s, server_hostname='nixg.ru')` → recv post-TLS stream header (from='nixg.ru')
4. `tls.getpeercert()` → subject/issuer/SAN. Issuer "Let's Encrypt" = trusted.
Run the ready-made probe: `python3 scripts/starttls_probe.py <host> <port> <domain> [--s2s]`
(s2s uses xmlns jabber:server and tests 5269-style handshakes).
## External testers
- xmpp.net (result.php) is dead/closed since ~2022. Current checker: inspect.xmpp.net.
- Testing the public IP from the SAME host trips the hairpin-NAT trap (traffic never leaves the box, DNAT never fires) — test from vps02 over WG, or from a phone / other VPS.
@@ -0,0 +1,87 @@
# User management & disabling in-band registration (Prosody 0.11, verified 2026-08-29)
Session-proven on the nixg.ru Prosody (Docker) — creates/verifies accounts,
and the CORRECT way to shut off self-registration.
## Account operations (prosodyctl — the ONLY path once registration is off)
```bash
# create (same command re-run = password change)
docker exec icq-prosody prosodyctl register <user> nixg.ru 'PASS'
# delete
docker exec icq-prosody prosodyctl unregister <user> nixg.ru
# list accounts (URL-encoded domain dir on host)
ls /opt/icq/data/nixg%2eru/accounts/ # *.dat per account
```
`prosodyctl check accounts` is NOT a valid subcommand (0.11). Verify creation
via the accounts dir, then by logging in.
## Disabling in-band self-registration — do BOTH, not just the flag
```lua
modules_enabled = { -- comment the module OUT:
-- "register";
...
}
VirtualHost "nixg.ru"
allow_registration = false -- AND flip the flag
```
- `allow_registration = false` alone leaves mod_register loaded and answering
XEP-0077 (it just refuses) — but only unloading the module makes Prosody
reply `service-unavailable`, which is the clean "registration closed" signal
clients understand.
- After edit: `docker exec icq-prosody prosodyctl check config` →
"All checks passed", then `docker compose restart prosody`.
## Verification: raw XEP-0077 probe from a client
Don't rely on client plugins (slixmpp's xep_0077 stanza may not be registered).
Send a raw IQ with an existing authenticated session:
```python
import asyncio, ssl
import slixmpp
async def main():
bot = slixmpp.ClientXMPP('user@nixg.ru', 'PASS')
ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
bot.ssl_context = ctx
result = asyncio.Event()
async def on_start(ev):
iq = bot.make_iq_set(sub=None, ito='nixg.ru')
iq['id'] = 'regtest1'
q = iq.xml.makeelement('{jabber:iq:register}query', {})
u = q.makeelement('username', {}); u.text = 'probeuser'
p = q.makeelement('password', {}); p.text = 'ProbePass1'
q.append(u); q.append(p); iq.xml.append(q)
def cb(resp):
print('REJECTED:', resp['type'], '/', resp['error']['condition'])
result.set(); bot.disconnect()
iq.send(callback=cb)
bot.add_event_handler('session_start', on_start)
await bot.connect(('localhost', 5222))
await asyncio.wait_for(result.wait(), timeout=15)
asyncio.run(main())
```
Expected with registration off: `REJECTED: error / service-unavailable`
(no account created). With registration on: error `conflict` only if the
username exists, otherwise success — so a probe also proves whether it is on.
## Verifying a login works (the reliable slixmpp pattern)
`await bot.connect(...)` then waiting on `bot.disconnected.wait()` CRASHES
(`'_asyncio.Future' object has no attribute 'wait'`). Use event handlers:
```python
done = asyncio.Event()
def on_session(ev): print('AUTH OK'); done.set()
def on_fail(ev): print('AUTH FAILED'); done.set()
bot.add_event_handler('session_start', on_session)
bot.add_event_handler('failed_auth', on_fail)
await bot.connect(('localhost', 5222))
await asyncio.wait_for(done.wait(), timeout=15)
```
`AUTH OK: session_start` = credentials valid. Works over c2s (localhost:5222)
or `wss://xmpp.nixg.ru/xmpp-websocket` — good for smoke-testing new accounts
and password resets.
@@ -0,0 +1,137 @@
# Web client (Converse.js) behind Caddy + Caddy bind-mount trap
Session detail from adding the web client to the ICQ XMPP project (2026-08-28).
## Converse.js web client architecture
Serve the web client with a tiny nginx container that BOTH serves static Converse.js AND
proxies the WebSocket to Prosody — so Caddy needs exactly ONE upstream (nginx:8081), not two.
`docker-compose.yml` addition (same compose project as prosody, so `prosody` resolves
by container name on the shared network):
```yaml
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"
depends_on:
- prosody
```
`webchat/nginx.conf`:
```nginx
server {
listen 8081;
server_name chat.nixg.ru;
root /usr/share/nginx/html;
index index.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";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location / { try_files $uri $uri/ /index.html; }
}
```
`webchat/index.html` — Converse.js from CDN:
```html
<link rel="stylesheet" href="https://cdn.conversejs.org/css/converse.min.css">
<div id="conversejs"></div>
<script src="https://cdn.conversejs.org/dist/converse.min.js"></script>
<script>
converse.initialize({
bosh_service_url: 'wss://chat.nixg.ru/xmpp-websocket',
theme: 'concord',
i18n: 'ru',
view_mode: 'overlayed', // or 'fullscreen'
allow_file_transfer: false, // needs http_upload component
});
</script>
```
Caddy block (point at nginx, NOT directly at Prosody 5280):
```
chat.nixg.ru {
reverse_proxy 10.8.0.2:8081 {
header_up Host {host}
header_up X-Forwarded-Proto https
}
}
```
## mod_websocket handshake requirements (diagnosing 501/403)
- A plain `curl` GET to `/xmpp-websocket` returns a generic "It works! Now point your
WebSocket client to this URL" page — that does NOT prove WebSocket works. You must send
a real WebSocket handshake.
- **`Sec-WebSocket-Protocol: xmpp` is REQUIRED.** Without it Prosody answers
**501 Not Implemented** ("Client didn't want to talk XMPP" — mod_websocket.lua ~line 217).
- With the protocol header but no allowed `Origin`, Prosody answers **403 Forbidden**
(cross-domain check, mod_websocket.lua ~line 225). Fix: GLOBAL config section
(above VirtualHost):
```lua
cross_domain_websocket = { "https://chat.nixg.ru" }
```
then restart prosody. Browser clients ALWAYS send `Origin`, so this must be set.
- curl cannot do a real WS handshake — its 501/400/403 results are expected noise, NOT
proof of breakage. Test with a raw-socket handshake script:
```python
import socket, base64, os
s = socket.create_connection(("127.0.0.1", 8081), timeout=6)
key = base64.b64encode(os.urandom(16)).decode()
req = (f"GET /xmpp-websocket HTTP/1.1\r\nHost: chat.nixg.ru\r\n"
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n"
"Sec-WebSocket-Protocol: xmpp\r\nOrigin: https://chat.nixg.ru\r\n\r\n")
s.sendall(req.encode())
resp = b""
while b"\r\n\r\n" not in resp:
resp += s.recv(4096)
print(resp.split(b"\r\n")[0].decode()) # expect HTTP/1.1 101 Switching Protocols
s.close()
```
## Caddy bind-mount inode trap (edit via tee/redirect silently ignored)
Editing `/opt/caddy/Caddyfile` on the host with `sudo tee` or `> redirect` creates a NEW
inode. The running caddy container keeps the OLD inode bound (bind mount), so:
- `docker exec caddy caddy reload --config /etc/caddy/Caddyfile` SUCCEEDS but serves the OLD config.
- `grep` of the file INSIDE the container differs from the file ON the host
(different inode & size via `stat`).
Fix: `docker compose restart caddy` (rebinds the mount to the new inode), then verify by
grepping the file inside the container.
Diagnostic that revealed it:
```bash
sudo stat -c "%i %s %y" /opt/caddy/Caddyfile # host inode
sudo docker exec caddy stat -c "%i %s %y" /etc/caddy/Caddyfile # container inode — differs!
# also: caddy adapt --config ... | grep upstreams shows the OLD dial IP
```
## Security group / provider firewall (Timeweb-style)
- Cloud providers (Timeweb etc.) default-close non-standard ports. Open TCP 5222 + 5269
in the provider's security group for the public VPS. XMPP uses TCP only — no UDP.
- When creating a security group, keep the broad egress rule (`Any/Any/0.0.0.0/0`); if the
group only allows metadata-IP egress, the VPS loses general internet (this was avoided —
the xmpp group was ADDITIONAL to the base group, so egress stayed open).
@@ -0,0 +1,28 @@
# Prosody 0.11 capability matrix (XEP audit) — verified 2026-08-29 on nixg.ru
Checked against `config/prosody.cfg.lua` + `docker exec icq-prosody ls /usr/lib/prosody/modules/`
+ logs. Use this to answer "is feature X supported?" for a Prosody 0.11.x deployment.
| Capability | XEP | How it works in 0.11 | Status |
|---|---|---|---|
| MUC (group chat) | XEP-0045 | `Component "conference.<dom>" "muc"` (module is a Component, NOT a host module — loading `muc` in modules_enabled errors) | ✅ |
| PubSub | XEP-0060 | `mod_pubsub` ships in stock BUT in a subdir (`/usr/lib/prosody/modules/mod_pubsub/mod_pubsub.lua`) and is **not loaded by default** — add `"pubsub"` to modules_enabled | ✅ (enable explicitly) |
| HTTP Upload | XEP-0363 | community `mod_http_upload` (NOT stock); Component `"upload.<dom>" "http_upload"`. Stock image has no mod_http_file_share either | ✅ (install module) |
| MAM (history) | XEP-0313 | `mod_mam` (stock) + `mod_muc_mam` (stock, loads on MUC component) | ✅ |
| Carbons | XEP-0280 | `mod_carbons` (stock) | ✅ |
| PEP | XEP-0163 | `mod_pep` (stock) | ✅ |
| OMEMO | XEP-0384 | **client-side only in 0.11**: no server `mod_omemo` exists (neither stock nor prosody-modules — verified: `mod_omemo` 404s on hg). Server role = PEP for key bundles. Gajim/Dino/Conversations/Monal handle the crypto; Converse v14 bundles libomemo | ⚠️ client-side |
| OTR | XEP-0364 | also client-side; nothing on the server | ⚠️ client-side |
| WebRTC (calls) | XEP-0166 (Jingle)/0343 | Jingle is client-side; no server Jingle module; server just carries c2s/websocket signalling | ⚠️ client-side |
| Bookmarks | XEP-0402 / XEP-0048 | via PEP node `storage:bookmarks` (`mod_pep`); no separate `mod_bookmarks` in 0.11 (prosody-modules has `mod_default_bookmarks` / `mod_group_bookmarks` if default bookmarks wanted) | ✅ via PEP |
| Recent rosters / roster versioning | XEP-0237 | `mod_roster` versioning | ✅ |
| Privileged Entity (bridge roster sync) | XEP-0356 | community `mod_privilege` + `privileged_entities` on VirtualHost | ✅ (install module) |
| External components (bridges) | XEP-0114 | `component_ports = { 5347 }` + Component stanza with per-stanza `component_secret` (global `component_secrets` table ignored in 0.11!) | ✅ |
## Key takeaways
- **No server-side OMEMO/WebRTC/Bookmarks modules exist for 0.11** — this is normal, not a
misconfiguration. Those features are client-side over PEP. Do not hunt for `mod_omemo`.
- **`mod_pubsub` is stock but off by default** — if PubSub/PEP-dependent features are missing,
check modules_enabled first.
- **Community modules install path from RF**: hg.prosody.im (see SKILL.md "Community modules").
- Remember: PEP node for OMEMO bundles requires `pep` + `pubsub` both enabled.