#!/usr/bin/env python3 """Probe: XEP-0356 mod_privilege functional test against a Prosody 13.0 stand. Connects as an external component (XEP-0114) telegram.test.nixg.ru, then sends a privileged roster IQ-get on behalf of user testuser1@test.nixg.ru. - mod_privilege working -> server replies type=result with the roster - not allowed -> server replies a forbidden/not-allowed error stanza Verified output (2026-08-30, stand /opt/icq/test, container icq-prosody-test): >>> component connected as telegram.test.nixg.ru >>> on_iq fired: type=result, id=priv-roster-1 >>> PRIVILEGE OK: mod_privilege ответил result (roster testuser1) NOTE: a trailing ">>> TIMEOUT" line is COSMETIC — comp.disconnect() races wait_until('disconnected'); the result line above was already printed. Expected stand config (full recipe: references/prosody-13-parallel-stand.md): - external component: module-less `Component "telegram.test.nixg.ru"` + component_secret (raises the 5347 listener — component_ports was REMOVED in 13.0) - VirtualHost: modules_enabled = { "privilege", ... } + privileged_entities for the component - host port 15347 -> container 5347 (component listener) """ import asyncio, logging, ssl import slixmpp from slixmpp import ComponentXMPP from slixmpp.xmlstream import ET from slixmpp.xmlstream.handler import Callback from slixmpp.xmlstream.matcher import MatchXPath HOST_PORT = 15347 # host port mapped to container's 5347 COMPONENT_JID = "telegram.test.nixg.ru" COMPONENT_SECRET = "test-secret-telegram-123" SERVER_DOMAIN = "test.nixg.ru" TARGET_USER = "testuser1@test.nixg.ru" logging.basicConfig(level=logging.WARNING, format='%(levelname)s %(name)s: %(message)s') # Set to DEBUG to see the raw XML exchange (RECV shows the advert and the IQ result): # logging.getLogger('slixmpp.xmlstream.xmlstream').setLevel(logging.DEBUG) async def main(): comp = ComponentXMPP(COMPONENT_JID, COMPONENT_SECRET, SERVER_DOMAIN) comp.ssl_context = ssl.create_default_context() comp.ssl_context.check_hostname = False comp.ssl_context.verify_mode = ssl.CERT_NONE def on_iq(ev): # The raw Callback on {jabber:component:accept}iq delivers a plain XML STRING, # NOT a stanza object — parse it before reading attributes/children. if isinstance(ev, str): ev = ET.fromstring(ev) print(f">>> on_iq fired: type={ev.get('type')}, id={ev.get('id')}") if ev.get('type') in ('result', 'error') and ev.get('id') == 'priv-roster-1': if ev.get('type') == 'result': print(f">>> PRIVILEGE OK: mod_privilege ответил result (roster {TARGET_USER})") for q in ev.findall('{jabber:iq:roster}query'): for it in q.findall('{jabber:iq:roster}item'): print(f" - {it.get('jid')} (sub={it.get('subscription')})") if not ev.findall('{jabber:iq:roster}query/*'): print(" (roster пуст — у testuser1 нет контактов)") else: conds = ev.findall('{urn:ietf:params:xml:ns:xmpp-stanzas}*') cond = conds[0].tag.split('}')[1] if conds else '?' print(f">>> PRIVILEGE FAILED: {cond}") comp.disconnect() def on_session_start(_ev): print(f">>> component connected as {COMPONENT_JID}") iq = comp.Iq() iq['id'] = 'priv-roster-1' iq['type'] = 'get' iq['to'] = TARGET_USER iq['from'] = COMPONENT_JID iq.append(ET.Element('{jabber:iq:roster}query')) comp.send(iq) comp.add_event_handler('session_start', on_session_start) comp.add_event_handler('disconnected', lambda ev: print(">>> component disconnected")) comp.add_event_handler('connection_failed', lambda ev: print(">>> connection_failed", ev)) # slixmpp 1.17 ComponentXMPP registers ONLY handshake + presence_probe handlers — # incoming IQ stanzas NEVER fire add_event_handler('iq', ...), even though the XML # arrives (visible in xmlstream DEBUG). Must register a raw Callback instead: comp.register_handler( Callback('IQPriv', MatchXPath('{jabber:component:accept}iq'), on_iq)) try: await asyncio.wait_for(comp.connect('127.0.0.1', HOST_PORT), 15) await asyncio.wait_for(comp.wait_until('disconnected'), 30) except asyncio.TimeoutError: print(">>> TIMEOUT (нет ответа от mod_privilege)") except Exception as e: print(f">>> EXC: {type(e).__name__}: {e}") asyncio.run(main())