From 3da6c8709b66f2edf6375f12f422ad85be29a5b9 Mon Sep 17 00:00:00 2001 From: estorozhenko Date: Sun, 6 Sep 2026 13:51:11 +0000 Subject: [PATCH] Initial commit: Hermes skill xmpp-server-prosody --- SKILL.md | 127 ++++++++++++++ references/backup-yandex-disk.md | 35 ++++ .../conversejs-blank-page-and-auth-testing.md | 161 ++++++++++++++++++ references/docker-ip-shift-nginx-stale-dns.md | 71 ++++++++ references/http-upload-xep0363.md | 55 ++++++ references/letsencrypt-dns01-prosody.md | 47 +++++ .../modules-and-restart-verification.md | 74 ++++++++ references/omemo-xep0384.md | 71 ++++++++ references/prosody-012-upgrade-path.md | 64 +++++++ references/prosody-13-parallel-stand.md | 155 +++++++++++++++++ references/prosody-13-production-migration.md | 79 +++++++++ references/push-notifications.md | 47 +++++ references/silent-watchdog-cron.md | 37 ++++ .../slidge-env-prefix-and-preset-api.md | 64 +++++++ references/slidge-flood-invites-and-roster.md | 102 +++++++++++ .../slidge-registration-and-avatar-gap.md | 78 +++++++++ .../slidge-registration-client-compat.md | 43 +++++ references/slidge-telegram-bridge-nixg.md | 117 +++++++++++++ .../slidge-telegram-folders-and-channels.md | 56 ++++++ references/telegram-bridge-slidge.md | 27 +++ references/tls-starttls-testing.md | 20 +++ .../user-management-registration-disable.md | 87 ++++++++++ .../webchat-conversejs-and-caddy-trap.md | 137 +++++++++++++++ .../xep-capability-matrix-prosody-011.md | 28 +++ scripts/check_icq_cert.sh | 41 +++++ scripts/privilege_probe.py | 98 +++++++++++ scripts/starttls_probe.py | 48 ++++++ templates/backup-yandex-disk.sh | 47 +++++ 28 files changed, 2016 insertions(+) create mode 100644 SKILL.md create mode 100644 references/backup-yandex-disk.md create mode 100644 references/conversejs-blank-page-and-auth-testing.md create mode 100644 references/docker-ip-shift-nginx-stale-dns.md create mode 100644 references/http-upload-xep0363.md create mode 100644 references/letsencrypt-dns01-prosody.md create mode 100644 references/modules-and-restart-verification.md create mode 100644 references/omemo-xep0384.md create mode 100644 references/prosody-012-upgrade-path.md create mode 100644 references/prosody-13-parallel-stand.md create mode 100644 references/prosody-13-production-migration.md create mode 100644 references/push-notifications.md create mode 100644 references/silent-watchdog-cron.md create mode 100644 references/slidge-env-prefix-and-preset-api.md create mode 100644 references/slidge-flood-invites-and-roster.md create mode 100644 references/slidge-registration-and-avatar-gap.md create mode 100644 references/slidge-registration-client-compat.md create mode 100644 references/slidge-telegram-bridge-nixg.md create mode 100644 references/slidge-telegram-folders-and-channels.md create mode 100644 references/telegram-bridge-slidge.md create mode 100644 references/tls-starttls-testing.md create mode 100644 references/user-management-registration-disable.md create mode 100644 references/webchat-conversejs-and-caddy-trap.md create mode 100644 references/xep-capability-matrix-prosody-011.md create mode 100644 scripts/check_icq_cert.sh create mode 100644 scripts/privilege_probe.py create mode 100644 scripts/starttls_probe.py create mode 100644 templates/backup-yandex-disk.sh diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..f833ab0 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,127 @@ +--- +name: xmpp-server-prosody +title: "XMPP Server Deployment (Prosody)" +description: "Deploy federated XMPP (Prosody in Docker) behind a proxy." +--- + +# XMPP Server Deployment (Prosody) + +Deploy a self-hosted federated XMPP messenger (family/team) with **Prosody** in Docker, exposed through a public VPS reverse proxy. Produced from the ICQ project (chat.nixg.ru) on the bigbox/vps02 infrastructure. + +## When to use +- User wants a self-hosted messenger (family, team) with iOS/Android clients and **federation** (talking to the whole XMPP network). +- Choosing XMPP: it is the federated standard (like email). **Do not** attempt to "clone Telegram/MTProto" — it is closed, centralised, non-federated. XMPP is the right answer when federation matters. +- Snikket vs Prosody: Snikket server is easier but assumes direct 443 access and is finicky behind a reverse proxy. **Prosody** is the right choice when going through a Caddy/reverse-proxy + WireGuard setup. + +## Architecture (this environment) + +``` +[Clients: Monal/Snikket/Conversations iOS+Android] + │ c2s 5222 (TLS) + ▼ +[bigbox] Prosody Docker (/opt/icq) — no public IP + │ s2s 5269 (federation) · http 5280 (websocket/BOSH) + ▼ +[vps02] Caddy (host-network, reverse_proxy 10.8.0.2:5280) + iptables DNAT 5222/5269 → 10.8.0.2 + ▲ + └─ WireGuard wg0 (10.8.0.x) +``` + +## DNS records (critical for federation) + +``` +A chat.nixg.ru → public IP of vps02 +SRV _xmpp-client._tcp.chat.nixg.ru → chat.nixg.ru:5222 (priority 0, weight 5) +SRV _xmpp-server._tcp.chat.nixg.ru → chat.nixg.ru:5269 (priority 0, weight 5) +A conference.chat.nixg.ru → public IP (MUC component host) +``` + +- **Without SRV records, other servers cannot find you → federation silently fails.** For a standard XMPP domain hosted at a subdomain, prosodyctl suggests using SRV to redirect; that is fine. +- A-record alone works for clients hardcoded to the domain, but SRV is what the federated network uses. + +## docker-compose.yml + +```yaml +services: + prosody: + image: prosody/prosody:latest + container_name: icq-prosody + restart: unless-stopped + hostname: chat.nixg.ru + volumes: + - ./data:/var/lib/prosody + - ./config:/etc/prosody + - ./certs:/etc/prosody/certs + - ./modules:/etc/prosody/modules + - ./logs:/var/log/prosody + ports: + - "5222:5222" # c2s clients + - "5269:5269" # s2s federation + - "5280:5280" # BOSH/websocket (behind Caddy) + # - "5281:5281" # https — SKIP unless you have a cert wired +``` + +Remove the obsolete `version:` attribute (Compose v2 warns). + +## prosody.cfg.lua pitfall checklist + +- **`modules_enabled` must NOT contain `muc`** — MUC is a Component (`Component "conference.chat.nixg.ru" "muc"`). Loading `muc` as a module errors out. +- **`muc_mam`** loads only on a MUC component, not on the host. +- **`log` block must be in the GLOBAL section**, above any VirtualHost/Component, or `prosodyctl check config` complains. +- **Community modules NOT in the stock image** (prosody/prosody:latest): `http_upload`, `smacks`, `s2s_bidi`, `xmpp_component`. They come from the prosody-modules community repo — either install them or leave them out. HTTP Upload (XEP-0363, needed for bot file delivery) requires installing `mod_http_upload` manually. +- **`prosodyctl register `** works inside the container; do NOT use `-it` (fails "cannot attach stdin"). Data dir is URL-encoded: `/var/lib/prosody/chat%2enixg%2eru/accounts/`. +- **https 5281 bind error** ("No certificate present") is benign — the stock image tries to bind it by default. Disable by not mapping the port; it does not break c2s/s2s/http. +- **Prosody 13.0 != 0.11 for external components**: `component_ports` was REMOVED (listener is hardcoded 5347); a module-less `Component "jid"` block is required to raise the listener; `component_interfaces` must be in the GLOBAL section; `log = {...}` may only appear ONCE in the config. mod_privilege (XEP-0356) needs the TIP version (promise API) and must be enabled on BOTH the component block and the VirtualHost. Full recipe + gotchas: `references/prosody-13-parallel-stand.md`. +- Validate: `docker exec icq-prosody prosodyctl check config` → "All checks passed". +- `allow_registration = true` enables in-band registration — flip off after family accounts are created. + +## Exposing through vps02 (Caddy + WireGuard + iptables) + +Caddy on vps02 runs `network_mode: host` with a plain Caddyfile. Add: + +``` +chat.nixg.ru { + reverse_proxy 10.8.0.2:5280 { + header_up Host {host} + header_up X-Forwarded-Proto https + } +} +``` + +Caddy auto-issues Let's Encrypt TLS. Reload: `docker exec caddy caddy reload --config /etc/caddy/Caddyfile`. + +For non-HTTP ports (5222 c2s, 5269 s2s) Caddy cannot proxy them — use iptables DNAT on vps02. Existing pattern (mirrors the 8443 rule already present): + +```bash +sudo iptables -t nat -A PREROUTING -p tcp --dport 5222 -j DNAT --to-destination 10.8.0.2:5222 +sudo iptables -t nat -A PREROUTING -p tcp --dport 5269 -j DNAT --to-destination 10.8.0.2:5269 +sudo iptables -I INPUT -p tcp --dport 5222 -j ACCEPT +sudo iptables -I INPUT -p tcp --dport 5269 -j ACCEPT +sudo iptables -I FORWARD 1 -d 10.8.0.2 -p tcp --dport 5222 -j ACCEPT +sudo iptables -I FORWARD 1 -d 10.8.0.2 -p tcp --dport 5269 -j ACCEPT +sudo iptables -t nat -A POSTROUTING -o wg0 -p tcp --dport 5222 -j MASQUERADE +sudo iptables -t nat -A POSTROUTING -o wg0 -p tcp --dport 5269 -j MASQUERADE +# persist: +sudo sh -c "iptables-save > /etc/iptables/rules.v4" +``` + +- **FORWARD policy is DROP** by default on vps02 — you MUST add FORWARD ACCEPT rules (the 8443 precedent has them). DNAT alone is not enough. +- Save via `sudo sh -c "iptables-save > /etc/iptables/rules.v4"` — plain `sudo iptables-save > file` fails (redirect runs as your user, not root). `netfilter-persistent` service is enabled. + +## Testing the external path — hairpin NAT trap + +- **Do NOT test a host's public ports from the same host** (or from a host that routes back into it). `ip route get ` shows `` → traffic never leaves the box, DNAT never fires. `Connection refused` from such tests is a FALSE NEGATIVE. +- Validate the path instead: + - From vps02 → `10.8.0.2:5222` over WG (real path, with tcpdump on bigbox wg0 shows the TCP handshake). + - From an EXTERNAL host (phone on mobile data, another VPS): `nc -vz chat.nixg.ru 5222`. +- If both internal paths work but an external probe fails, suspect the **provider's cloud firewall panel** (Timeweb etc.) — ports 5222/5269 are commonly closed there by default. Ask the user to open them in the provider panel. +- 443 via Caddy IS externally testable from anywhere; a working 443 + working WG path to 5222 usually means only the provider firewall is in the way. + +## Bots (slixmpp) and file delivery + +- An XMPP bot is just a second user account (JID) — no separate Bot API. Python `slixmpp` is the live, mature library. +- To send files (e.g. a book-download bot), the server needs HTTP Upload (XEP-0363) — community module `mod_http_upload` (not in stock image). The client sends the file to the upload component; the recipient gets a link. +- Bot pattern: user messages `book@chat.nixg.ru` → bot queries an OPDS catalog (Flibusta et al.) → downloads epub/fb2 → uploads via HTTP Upload → sends link. + +## References +- `references/vps02-bigbox-wg-access.md` — exact vps02/bigbox network topology, Caddyfile, iptables persistence and hairpin-test evidence. \ No newline at end of file diff --git a/references/backup-yandex-disk.md b/references/backup-yandex-disk.md new file mode 100644 index 0000000..7208108 --- /dev/null +++ b/references/backup-yandex-disk.md @@ -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`) удалять после проверки — они не должны оставаться. diff --git a/references/conversejs-blank-page-and-auth-testing.md b/references/conversejs-blank-page-and-auth-testing.md new file mode 100644 index 0000000..f2e7f7a --- /dev/null +++ b/references/conversejs-blank-page-and-auth-testing.md @@ -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 ` + +``` + +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.) \ No newline at end of file diff --git a/references/docker-ip-shift-nginx-stale-dns.md b/references/docker-ip-shift-nginx-stale-dns.md new file mode 100644 index 0000000..5769df0 --- /dev/null +++ b/references/docker-ip-shift-nginx-stale-dns.md @@ -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. \ No newline at end of file diff --git a/references/http-upload-xep0363.md b/references/http-upload-xep0363.md new file mode 100644 index 0000000..5921d7b --- /dev/null +++ b/references/http-upload-xep0363.md @@ -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 → ` (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: - 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). \ No newline at end of file diff --git a/references/letsencrypt-dns01-prosody.md b/references/letsencrypt-dns01-prosody.md new file mode 100644 index 0000000..8b0dc82 --- /dev/null +++ b/references/letsencrypt-dns01-prosody.md @@ -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 @ --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 +_acme-challenge.xmpp.nixg.ru TXT +``` +- 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/.crt` / `.key`. Log line to confirm: `: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). \ No newline at end of file diff --git a/references/modules-and-restart-verification.md b/references/modules-and-restart-verification.md new file mode 100644 index 0000000..136c3ab --- /dev/null +++ b/references/modules-and-restart-verification.md @@ -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: ` + `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. diff --git a/references/omemo-xep0384.md b/references/omemo-xep0384.md new file mode 100644 index 0000000..663e2f4 --- /dev/null +++ b/references/omemo-xep0384.md @@ -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//pep_eu%2esiacs%2econversations%2eaxolotl%2edevicelist/admin.list` + → grep `["id"] = "N"` — все устройства пользователя (например 4040, 14035). +- bundle: `data//pep_eu%2esiacs%2econversations%2eaxolotl%2ebundles%3a/admin.list` + → должен содержать `identityKey`, 100× `preKeyPublic` (preKeyId 0..99), + `signedPreKeyPublic` + `signedPreKeySignature`, имя `bundle`. +- : 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:. + +## Очистка/удаление своего устройства из 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. \ No newline at end of file diff --git a/references/prosody-012-upgrade-path.md b/references/prosody-012-upgrade-path.md new file mode 100644 index 0000000..bdd6958 --- /dev/null +++ b/references/prosody-012-upgrade-path.md @@ -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. \ No newline at end of file diff --git a/references/prosody-13-parallel-stand.md b/references/prosody-13-parallel-stand.md new file mode 100644 index 0000000..23703ac --- /dev/null +++ b/references/prosody-13-parallel-stand.md @@ -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. \ No newline at end of file diff --git a/references/prosody-13-production-migration.md b/references/prosody-13-production-migration.md new file mode 100644 index 0000000..c6719d2 --- /dev/null +++ b/references/prosody-13-production-migration.md @@ -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." "pubsub"`. Without this, startup fails: + `Error initializing module 'pubsub' on '': 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: ` + (~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." "http_upload" + http_upload_access = { "telegram." } + ``` + - 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 +`` 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 `` +→ 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. diff --git a/references/push-notifications.md b/references/push-notifications.md new file mode 100644 index 0000000..1591f63 --- /dev/null +++ b/references/push-notifications.md @@ -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/`, restart, re-grep for `Error initializing` (should be empty). diff --git a/references/silent-watchdog-cron.md b/references/silent-watchdog-cron.md new file mode 100644 index 0000000..9c37fbb --- /dev/null +++ b/references/silent-watchdog-cron.md @@ -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:`) 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. diff --git a/references/slidge-env-prefix-and-preset-api.md b/references/slidge-env-prefix-and-preset-api.md new file mode 100644 index 0000000..926f2ef --- /dev/null +++ b/references/slidge-env-prefix-and-preset-api.md @@ -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= + - SLIDGE__SLIDGRAM_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. \ No newline at end of file diff --git a/references/slidge-flood-invites-and-roster.md b/references/slidge-flood-invites-and-roster.md new file mode 100644 index 0000000..5689aa5 --- /dev/null +++ b/references/slidge-flood-invites-and-roster.md @@ -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-@telegram.nixg.ru/` 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=` → +`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-@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 '%%'` + — 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. \ No newline at end of file diff --git a/references/slidge-registration-and-avatar-gap.md b/references/slidge-registration-and-avatar-gap.md new file mode 100644 index 0000000..dcca971 --- /dev/null +++ b/references/slidge-registration-and-avatar-gap.md @@ -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). \ No newline at end of file diff --git a/references/slidge-registration-client-compat.md b/references/slidge-registration-client-compat.md new file mode 100644 index 0000000..9afae10 --- /dev/null +++ b/references/slidge-registration-client-compat.md @@ -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. \ No newline at end of file diff --git a/references/slidge-telegram-bridge-nixg.md b/references/slidge-telegram-bridge-nixg.md new file mode 100644 index 0000000..32541a1 --- /dev/null +++ b/references/slidge-telegram-bridge-nixg.md @@ -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 = "" -- 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= # 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 `. +- **`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. \ No newline at end of file diff --git a/references/slidge-telegram-folders-and-channels.md b/references/slidge-telegram-folders-and-channels.md new file mode 100644 index 0000000..5220113 --- /dev/null +++ b/references/slidge-telegram-folders-and-channels.md @@ -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-` — 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. \ No newline at end of file diff --git a/references/telegram-bridge-slidge.md b/references/telegram-bridge-slidge.md new file mode 100644 index 0000000..08a631e --- /dev/null +++ b/references/telegram-bridge-slidge.md @@ -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"] = "" } + ``` + 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 diff --git a/references/tls-starttls-testing.md b/references/tls-starttls-testing.md new file mode 100644 index 0000000..c4caca1 --- /dev/null +++ b/references/tls-starttls-testing.md @@ -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 ``. +- `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 ``. 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 `` → recv features (starttls, SCRAM-SHA-1) +2. send `` → recv `` +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 [--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. \ No newline at end of file diff --git a/references/user-management-registration-disable.md b/references/user-management-registration-disable.md new file mode 100644 index 0000000..389693c --- /dev/null +++ b/references/user-management-registration-disable.md @@ -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 nixg.ru 'PASS' +# delete +docker exec icq-prosody prosodyctl unregister 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. \ No newline at end of file diff --git a/references/webchat-conversejs-and-caddy-trap.md b/references/webchat-conversejs-and-caddy-trap.md new file mode 100644 index 0000000..b136f22 --- /dev/null +++ b/references/webchat-conversejs-and-caddy-trap.md @@ -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 + +
+ + +``` + +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). \ No newline at end of file diff --git a/references/xep-capability-matrix-prosody-011.md b/references/xep-capability-matrix-prosody-011.md new file mode 100644 index 0000000..1ad6417 --- /dev/null +++ b/references/xep-capability-matrix-prosody-011.md @@ -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." "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." "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. \ No newline at end of file diff --git a/scripts/check_icq_cert.sh b/scripts/check_icq_cert.sh new file mode 100644 index 0000000..891059d --- /dev/null +++ b/scripts/check_icq_cert.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Silent LE-cert watchdog for manual dns-01 renewal (Prosody reading certs/.crt). +# Cron contract (Hermes no_agent cron): empty stdout = SILENT (nothing delivered to the user); +# non-empty stdout is delivered verbatim. So stay quiet until renewal is actually due. +# Deployed copy (ICQ): /opt/hermes/.hermes/scripts/check_icq_cert.sh, cron job ca2a23a34905, +# daily 0 10 * * *, deliver=telegram: (explicit target required from CLI sessions). +# Usage: check_icq_cert.sh [CERT_PATH] (default /opt/icq/certs/nixg.ru.crt) +# Override: THRESHOLD=30 check_icq_cert.sh (days before expiry to start shouting) +CERT="${1:-/opt/icq/certs/nixg.ru.crt}" +THRESHOLD="${THRESHOLD:-45}" + +if [ ! -f "$CERT" ]; then + echo "⚠️ ОТСУТСТВУЕТ сертификат $CERT — XMPP c2s/s2s скоро перестанет работать!" + echo "Проверь бэкап и восстанови (см. backup.sh)." + exit 0 +fi + +END=$(openssl x509 -enddate -noout -in "$CERT" | cut -d= -f2) +END_EPOCH=$(date -d "$END" +%s) +NOW_EPOCH=$(date +%s) +DAYS=$(( (END_EPOCH - NOW_EPOCH) / 86400 )) + +if [ "$DAYS" -gt "$THRESHOLD" ]; then + exit 0 # тишина: до истечения больше порога, напоминание не нужно +fi + +# ---- Шумим: до истечения <= THRESHOLD дней ---- +echo "🔐 LE-сертификат истекает через ${DAYS} дн. (${END}). Нужно продлить вручную через dns-01:" +echo "" +echo "1. sudo certbot renew --manual-auth-hook /bin/true --manual-cleanup-hook /bin/true" +echo " → он напечатает НОВЫЕ TXT-значения для _acme-challenge.nixg.ru и _acme-challenge.xmpp.nixg.ru" +echo "2. Добавить эти TXT-записи в панели DNS (заменить старые)" +echo "3. Подождать 1–2 мин, проверить: dig @1.1.1.1 _acme-challenge.nixg.ru TXT +short" +echo "4. Повторить ту же команду certbot renew — сертификат продлится" +echo "5. Скопировать свежий серт:" +echo " sudo cp -L /etc/letsencrypt/live/nixg.ru/fullchain.pem /opt/icq/certs/nixg.ru.crt" +echo " sudo cp -L /etc/letsencrypt/live/nixg.ru/privkey.pem /opt/icq/certs/nixg.ru.key" +echo "6. Перезапустить Prosody: cd /opt/icq && docker compose restart prosody" +echo "7. Проверить: openssl x509 -enddate -noout -in /opt/icq/certs/nixg.ru.crt" + +# Домашняя проверка (офлайн): запусти с THRESHOLD=100 → должен зашуметь; обычный запуск → пустой вывод. diff --git a/scripts/privilege_probe.py b/scripts/privilege_probe.py new file mode 100644 index 0000000..7643295 --- /dev/null +++ b/scripts/privilege_probe.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Probe: XEP-0356 mod_privilege functional test against a Prosody 13.0 stand. + +Connects as an external component (XEP-0114) telegram.test.nixg.ru, then sends a +privileged roster IQ-get on behalf of user testuser1@test.nixg.ru. +- mod_privilege working -> server replies type=result with the roster +- not allowed -> server replies a forbidden/not-allowed error stanza + +Verified output (2026-08-30, stand /opt/icq/test, container icq-prosody-test): + + >>> component connected as telegram.test.nixg.ru + >>> on_iq fired: type=result, id=priv-roster-1 + >>> PRIVILEGE OK: mod_privilege ответил result (roster testuser1) + +NOTE: a trailing ">>> TIMEOUT" line is COSMETIC — comp.disconnect() races +wait_until('disconnected'); the result line above was already printed. + +Expected stand config (full recipe: references/prosody-13-parallel-stand.md): +- external component: module-less `Component "telegram.test.nixg.ru"` + component_secret + (raises the 5347 listener — component_ports was REMOVED in 13.0) +- VirtualHost: modules_enabled = { "privilege", ... } + privileged_entities for the component +- host port 15347 -> container 5347 (component listener) +""" +import asyncio, logging, ssl + +import slixmpp +from slixmpp import ComponentXMPP +from slixmpp.xmlstream import ET +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import MatchXPath + +HOST_PORT = 15347 # host port mapped to container's 5347 +COMPONENT_JID = "telegram.test.nixg.ru" +COMPONENT_SECRET = "test-secret-telegram-123" +SERVER_DOMAIN = "test.nixg.ru" +TARGET_USER = "testuser1@test.nixg.ru" + +logging.basicConfig(level=logging.WARNING, format='%(levelname)s %(name)s: %(message)s') +# Set to DEBUG to see the raw XML exchange (RECV shows the advert and the IQ result): +# logging.getLogger('slixmpp.xmlstream.xmlstream').setLevel(logging.DEBUG) + + +async def main(): + comp = ComponentXMPP(COMPONENT_JID, COMPONENT_SECRET, SERVER_DOMAIN) + comp.ssl_context = ssl.create_default_context() + comp.ssl_context.check_hostname = False + comp.ssl_context.verify_mode = ssl.CERT_NONE + + def on_iq(ev): + # The raw Callback on {jabber:component:accept}iq delivers a plain XML STRING, + # NOT a stanza object — parse it before reading attributes/children. + if isinstance(ev, str): + ev = ET.fromstring(ev) + print(f">>> on_iq fired: type={ev.get('type')}, id={ev.get('id')}") + if ev.get('type') in ('result', 'error') and ev.get('id') == 'priv-roster-1': + if ev.get('type') == 'result': + print(f">>> PRIVILEGE OK: mod_privilege ответил result (roster {TARGET_USER})") + for q in ev.findall('{jabber:iq:roster}query'): + for it in q.findall('{jabber:iq:roster}item'): + print(f" - {it.get('jid')} (sub={it.get('subscription')})") + if not ev.findall('{jabber:iq:roster}query/*'): + print(" (roster пуст — у testuser1 нет контактов)") + else: + conds = ev.findall('{urn:ietf:params:xml:ns:xmpp-stanzas}*') + cond = conds[0].tag.split('}')[1] if conds else '?' + print(f">>> PRIVILEGE FAILED: {cond}") + comp.disconnect() + + def on_session_start(_ev): + print(f">>> component connected as {COMPONENT_JID}") + iq = comp.Iq() + iq['id'] = 'priv-roster-1' + iq['type'] = 'get' + iq['to'] = TARGET_USER + iq['from'] = COMPONENT_JID + iq.append(ET.Element('{jabber:iq:roster}query')) + comp.send(iq) + + comp.add_event_handler('session_start', on_session_start) + comp.add_event_handler('disconnected', lambda ev: print(">>> component disconnected")) + comp.add_event_handler('connection_failed', lambda ev: print(">>> connection_failed", ev)) + + # slixmpp 1.17 ComponentXMPP registers ONLY handshake + presence_probe handlers — + # incoming IQ stanzas NEVER fire add_event_handler('iq', ...), even though the XML + # arrives (visible in xmlstream DEBUG). Must register a raw Callback instead: + comp.register_handler( + Callback('IQPriv', MatchXPath('{jabber:component:accept}iq'), on_iq)) + + try: + await asyncio.wait_for(comp.connect('127.0.0.1', HOST_PORT), 15) + await asyncio.wait_for(comp.wait_until('disconnected'), 30) + except asyncio.TimeoutError: + print(">>> TIMEOUT (нет ответа от mod_privilege)") + except Exception as e: + print(f">>> EXC: {type(e).__name__}: {e}") + + +asyncio.run(main()) \ No newline at end of file diff --git a/scripts/starttls_probe.py b/scripts/starttls_probe.py new file mode 100644 index 0000000..1083128 --- /dev/null +++ b/scripts/starttls_probe.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""STARTTLS probe for Prosody/XMPP servers (c2s 5222 default, --s2s uses jabber:server for 5269). + +Usage: starttls_probe.py [--s2s] + +Verifies: stream features -> starttls -> -> TLS handshake -> cert subject/issuer/SAN. +Reason to exist: `openssl s_client -starttls xmpp` reports "no peer certificate"/Cipher NONE +on Prosody 0.11 even when TLS works fine (false negative). + +Exit 0 with "TLS OK" = c2s/s2s STARTTLS fully working with a trusted cert. +""" +import socket, ssl, sys, time + +HOST = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1" +PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 5222 +DOMAIN = sys.argv[3] if len(sys.argv) > 3 else "nixg.ru" +S2S = "--s2s" in sys.argv + +NS = "jabber:server" if S2S else "jabber:client" +s = socket.create_connection((HOST, PORT), timeout=10) +s.settimeout(8) + + +def recv(): + data = s.recv(8192) + print(" <<", data.decode(errors="replace")[:300].replace("\n", " ")) + return data + + +s.sendall( + ("" % (DOMAIN, NS)).encode() +) +time.sleep(1) +recv() +s.sendall(b"") +time.sleep(1) +recv() +ctx = ssl.create_default_context() +tls = ctx.wrap_socket(s, server_hostname=DOMAIN) +print("TLS OK:", tls.cipher()[0], tls.version()) +try: + cert = tls.getpeercert() + print("SUBJECT:", cert["subject"]) + print("ISSUER:", cert["issuer"]) + print("SAN:", cert.get("subjectAltName")) +except Exception as e: + print("cert verification issue:", e) +tls.close() \ No newline at end of file diff --git a/templates/backup-yandex-disk.sh b/templates/backup-yandex-disk.sh new file mode 100644 index 0000000..7adffc9 --- /dev/null +++ b/templates/backup-yandex-disk.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Шаблон резервного копирования Docker-проекта на Яндекс.Диск. +# Является копией рабочего /opt/icq/backup.sh (по образцу /opt/netbox/backup.sh). +# Подходит для любого self-hosted проекта: data/ (тома бд/данных) + config + .env + доки. +# ЗАМЕНИТЕ: PROJECT_DIR, INCLUDE, YADISK_TARGET, имена файлов. + +PROJECT_DIR="/opt/PROJECT" # корень проекта +BACKUP_DIR="${PROJECT_DIR}/backups" +YADISK_MOUNT="/mnt/yandex-disk" +YADISK_TARGET="${YADISK_MOUNT}/backup/PROJECT-backups" +DATE=$(date +%Y%m%d_%H%M%S) + +# Папки/файлы проекта из $PROJECT_DIR, которые попадают в архив. +INCLUDE="data config certs modules webchat docker-compose.yml .env README.md" +PREFIX="project" # префикс имени архива (ротация ищет ${PREFIX}_*) + +mkdir -p ${BACKUP_DIR} +echo "🔄 [$(date)] Начинаем резервное копирование ${PROJECT_DIR}..." + +# Яндекс.Диск должен быть примонтирован (обычно @reboot через davfs) +if ! mountpoint -q ${YADISK_MOUNT}; then + echo " ❌ ОШИБКА: Яндекс.Диск не примонтирован!" + exit 1 +fi +mkdir -p ${YADISK_TARGET} 2>/dev/null + +ARCHIVE="${BACKUP_DIR}/${PREFIX}_${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 + +# Ротация: локально 7 дней, на ЯД 30 дней +find ${BACKUP_DIR} -type f -name "${PREFIX}_*" -mtime +7 -delete 2>/dev/null +find ${YADISK_TARGET} -type f -name "${PREFIX}_*" -mtime +30 -delete 2>/dev/null + +echo "✅ [$(date)] Резервное копирование завершено!" +echo "" +echo "📊 Созданные бэкапы:" +ls -lh ${BACKUP_DIR}/${PREFIX}_${DATE}.* 2>/dev/null || echo " (нет файлов)"