#!/usr/bin/env python3 """Live MUC membership recovery across SIGTERM over real loopback STARTTLS. Usage: python3 tests/muc_restart_network.py target/release/rust-xmpp The sockets stay open until shutdown, so ordinary disconnect cleanup cannot substitute for checkpointing the room's live occupants. """ import json from pathlib import Path import socket import subprocess import sys import tempfile import time import xml.etree.ElementTree as ET from _wire_client import Client, local MUC = 'http://jabber.org/protocol/muc' USER = MUC + '#user' ROOMS = ('persistent@rooms.localhost', 'temporary@rooms.localhost') def iq(client, identifier, payload, room, error=False): client.send(f"{payload}") return client.iq(identifier, error=error) def presence(client, room, nick): return client.take(lambda e: local(e) == 'presence' and e.get('from') == f'{room}/{nick}') def occupant(packet, role, affiliation=None): assert packet.get('type') is None, ET.tostring(packet) item = packet.find(f'{{{USER}}}x/{{{USER}}}item') assert item is not None and item.get('role') == role, ET.tostring(packet) if affiliation is not None: assert item.get('affiliation') == affiliation, ET.tostring(packet) def join(client, room, nick): client.send(f"awaybefore restart") return presence(client, room, nick) def not_joined(client, room): # A ping fences the initial broadcast presence without timeout-based reads. client.sync() assert not any(local(e) == 'presence' and e.get('from', '').startswith(room + '/') for e in client.queue), [ET.tostring(e) for e in client.queue] client.send(f"must not route") response = client.take(lambda e: local(e) == 'message' and e.get('id') == 'not-joined') assert response.get('type') == 'error', ET.tostring(response) assert response.find('.//{urn:ietf:params:xml:ns:xmpp-stanzas}not-acceptable') is not None, ET.tostring(response) def start(binary, config, port, log): server = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log) for _ in range(100): assert server.poll() is None, 'server stopped during startup' try: with socket.create_connection(('127.0.0.1', port), timeout=.1): return server except OSError: time.sleep(.05) server.kill() server.wait() raise AssertionError('server did not start') def stop(server): server.terminate() try: code = server.wait(timeout=10) except subprocess.TimeoutExpired: server.kill() server.wait() raise AssertionError('SIGTERM did not complete orderly shutdown') assert code == 0, f'SIGTERM shutdown exited {code}' def run(binary): with tempfile.TemporaryDirectory(prefix='xmpp-muc-restart-') as tmp: root = Path(tmp) subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem'), '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) with socket.socket() as probe: probe.bind(('127.0.0.1', 0)) port = probe.getsockname()[1] config = root/'config.json' config.write_text(json.dumps({'global': { 'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [], 'modules_enabled': ['tls', 'saslauth', 'ping', 'disco'], }, 'hosts': { 'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}, 'rooms.localhost': {'component': 'muc', 'options': {}}, }})) for user in ('alice', 'bob', 'carol'): subprocess.run([binary, 'create-user', str(config), user, '--password-stdin'], input='secret\n', text=True, check=True, capture_output=True) clients = [] server = None with (root/'server.log').open('w+') as log: try: server = start(binary, config, port, log) alice = Client(port, root/'cert.pem', 'alice', 'secret', 'desktop') bob = Client(port, root/'cert.pem', 'bob', 'secret', 'desktop') clients.extend((alice, bob)) alice.available() bob.available() for index, room in enumerate(ROOMS): occupant(join(alice, room, 'Alice'), 'moderator', 'owner') fields = ("http://jabber.org/protocol/muc#roomconfig" f"{int(index == 0)}") iq(alice, f'configure-{index}', f"{fields}", room) occupant(join(bob, room, 'Bob'), 'participant', 'none') iq(alice, f'moderator-{index}', f"", room) bob.take(lambda e: local(e) == 'presence' and e.get('from') == room + '/Bob' and e.find(f'{{{USER}}}x/{{{USER}}}item').get('role') == 'moderator') alice.sync() bob.sync() stop(server) server = None for client in clients: client.close() clients.clear() server = start(binary, config, port, log) other_resource = Client(port, root/'cert.pem', 'alice', 'secret', 'phone') carol = Client(port, root/'cert.pem', 'carol', 'secret', 'desktop') clients.extend((other_resource, carol)) other_resource.available() carol.available() for room in ROOMS: not_joined(other_resource, room) not_joined(carol, room) bob = Client(port, root/'cert.pem', 'bob', 'secret', 'desktop') alice = Client(port, root/'cert.pem', 'alice', 'secret', 'desktop') clients.extend((bob, alice)) bob.send('chatafter restart') for room in ROOMS: packet = presence(bob, room, 'Bob') occupant(packet, 'moderator', 'none') alice.send('awayrestored owner') for room in ROOMS: occupant(presence(alice, room, 'Alice'), 'moderator', 'owner') packet = presence(bob, room, 'Alice') occupant(packet, 'moderator', 'owner') assert packet.find('{jabber:client}show').text == 'away', ET.tostring(packet) assert packet.find('{jabber:client}status').text == 'before restart', ET.tostring(packet) alice.send(f"chatupdated after recovery") updated = presence(bob, room, 'Alice') assert updated.find('{jabber:client}status').text == 'updated after recovery', ET.tostring(updated) carol.send(f"") denied = presence(carol, room, 'Alice') assert denied.get('type') == 'error', ET.tostring(denied) assert denied.find('.//{urn:ietf:params:xml:ns:xmpp-stanzas}conflict') is not None, ET.tostring(denied) text = 'after recovery ' + room alice.send(f"{text}") assert bob.body(text).get('from') == room + '/Alice' assert alice.body(text).get('from') == room + '/Alice' # Explicit joins remain valid for another authenticated resource. occupant(join(other_resource, room, 'Phone'), 'moderator', 'owner') stop(server) server = None finally: if server is not None and server.poll() is None: server.terminate() try: server.wait(timeout=5) except subprocess.TimeoutExpired: server.kill() server.wait() for client in clients: client.close() if sys.exc_info()[0] is not None: log.seek(0) print(log.read(), file=sys.stderr) print('PASS: STARTTLS MUC SIGTERM restart restores live temporary/persistent rooms, exact full-JID membership, roles, presence and groupchat routing; rejects automatic cross-resource/account claims and nickname theft') if __name__ == '__main__': run(str(Path(sys.argv[1]).resolve()))