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,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()
|
||||
Reference in New Issue
Block a user