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