#!/usr/bin/env python3 """SIGKILL MUC durability over real TCP/STARTTLS (no shutdown checkpoint). Usage: python3 tests/muc_crash_network.py target/release/rust-xmpp All crashes happen with sockets open, immediately after the relevant wire reply. The grace test ages only the persisted clock while the server is stopped. """ import json from pathlib import Path import re import signal import socket import subprocess import sys import tempfile import time import xml.etree.ElementTree as ET from xml.sax.saxutils import escape from _wire_client import Client, local from muc_restart_network import MUC, USER, iq, occupant, presence, start, not_joined STANZAS = 'urn:ietf:params:xml:ns:xmpp-stanzas' def join(client, room, nick, history=0, password=None): secret = '' if password is None else f'{escape(password)}' client.send(f"{secret}" f"away" 'durable presence') return presence(client, room, nick) def configure(client, room, **values): fields = "http://jabber.org/protocol/muc#roomconfig" fields += ''.join(f"{escape(str(value))}" for key, value in values.items()) return iq(client, 'config', f"{fields}", room) def role(client, room, nick, value): return iq(client, 'role', f"", room) def denied(packet, condition): assert packet.get('type') == 'error', ET.tostring(packet) assert packet.find(f'.//{{{STANZAS}}}{condition}') is not None, ET.tostring(packet) class Server: def __init__(self, binary, root, options=None): self.binary, self.root = binary, root self.clients, self.process = [], None 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)) self.port = probe.getsockname()[1] self.config = root/'config.json' self.config.write_text(json.dumps({'global': { 'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [self.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': {'max_history_messages': 20, **(options or {})}}, }})) for user in ('alice', 'bob', 'carol'): subprocess.run([binary, 'create-user', str(self.config), user, '--password-stdin'], input='secret\n', text=True, check=True, capture_output=True) self.log = (root/'server.log').open('w+') self.restart() def client(self, user, resource='desktop', available=True): client = Client(self.port, self.root/'cert.pem', user, 'secret', resource) self.clients.append(client) if available: client.available() return client def restart(self): self.process = start(self.binary, self.config, self.port, self.log) def crash(self): # Never close a client first: disconnect cleanup must not save the state. self.process.kill() assert self.process.wait(timeout=10) == -signal.SIGKILL self.process = None for client in self.clients: client.close() self.clients.clear() def close(self): if self.process is not None: self.crash() if sys.exc_info()[0] is not None: self.log.seek(0) print(self.log.read(), file=sys.stderr) self.log.close() def recovery_and_history(server): rooms = ('persistent@rooms.localhost', 'temporary@rooms.localhost') alice, phone, bob = server.client('alice'), server.client('alice', 'phone'), server.client('bob') for index, room in enumerate(rooms): occupant(join(alice, room, 'Alice'), 'moderator', 'owner') configure(alice, room, persistentroom=int(index == 0), enablearchiving=0, historylength=20) # Exercise both shared-nickname sessions and distinct resource nicknames. occupant(join(phone, room, 'Alice' if index == 0 else 'Phone'), 'moderator', 'owner') occupant(join(bob, room, 'Bob'), 'participant', 'none') role(alice, room, 'Bob', 'moderator') for number in range(3): body = f'{room} precrash {number}' alice.send(f"{body}") bob.body(body) server.crash() server.restart() wrong = server.client('alice', 'tablet') stranger = server.client('carol') for room in rooms: not_joined(wrong, room) not_joined(stranger, room) # A dormant exact-resource claim reserves its nick before activation. denied(join(stranger, room, 'Alice'), 'conflict') bob = server.client('bob') for room in rooms: occupant(presence(bob, room, 'Bob'), 'moderator', 'none') alice = server.client('alice') phone = server.client('alice', 'phone') for index, room in enumerate(rooms): packet = presence(alice, room, 'Alice') occupant(packet, 'moderator', 'owner') status = packet.find('{jabber:client}status') assert status is not None and status.text == 'durable presence', ET.tostring(packet) phone_nick = 'Alice' if index == 0 else 'Phone' occupant(presence(phone, room, phone_nick), 'moderator', 'owner') body = room + ' recovered route' phone.send(f"{body}") assert bob.body(body).get('from') == room + '/' + phone_nick assert alice.body(body).get('from') == room + '/' + phone_nick occupant(join(stranger, room, 'History', history=20), 'participant', 'none') for number in range(3): packet = stranger.body(f'{room} precrash {number}') assert packet.find('{urn:xmpp:delay}delay') is not None, ET.tostring(packet) stranger.body(body) def mutation_barrier(server, mutation): room = mutation + '@rooms.localhost' alice, bob = server.client('alice'), server.client('bob') occupant(join(alice, room, 'Alice'), 'moderator', 'owner') configure(alice, room, persistentroom=1) occupant(join(bob, room, 'Bob'), 'participant', 'none') if mutation == 'leave': bob.send(f"") packet = bob.take(lambda e: local(e) == 'presence' and e.get('from') == room + '/Bob' and e.get('type') == 'unavailable') assert packet.find(f'{{{USER}}}x/{{{USER}}}item').get('role') == 'none', ET.tostring(packet) elif mutation == 'kick': role(alice, room, 'Bob', 'none') elif mutation == 'demotion': role(alice, room, 'Bob', 'moderator') role(alice, room, 'Bob', 'visitor') elif mutation == 'password': configure(alice, room, roomsecret='new-private-password') elif mutation == 'configuration': configure(alice, room, membersonly=1) else: raise AssertionError(mutation) # No ping, sleep, extra read or orderly close between acknowledgment and kill. server.crash() server.restart() bob = server.client('bob') if mutation in ('leave', 'kick', 'configuration'): not_joined(bob, room) elif mutation == 'demotion': occupant(presence(bob, room, 'Bob'), 'visitor', 'none') bob.send(f"forbidden") packet = bob.take(lambda e: local(e) == 'message' and e.get('id') == 'visitor') assert packet.get('type') == 'error', ET.tostring(packet) stranger = server.client('carol') if mutation == 'password': denied(join(stranger, room, 'Carol'), 'not-authorized') occupant(join(stranger, room, 'Carol', password='new-private-password'), 'participant', 'none') elif mutation == 'configuration': denied(join(stranger, room, 'Carol'), 'registration-required') def checkpoint_clock(server, age=None): """Age the committed journal clock without changing membership or config.""" files = list((server.root/'data').rglob('rust_muc_checkpoint.dat')) found = [] pattern = re.compile(r'(\["saved_at"\]\s*=\s*)(\d+)') for path in files: text = path.read_text() if pattern.search(text): found.append(int(pattern.search(text).group(2))) if age is not None: path.write_text(pattern.sub(lambda m: m.group(1) + str(int(time.time()) - age), text)) assert found, 'no committed recovery checkpoint timestamp found' return found def restart_grace(server, grace=300): room = 'grace@rooms.localhost' alice = server.client('alice') occupant(join(alice, room, 'Alice'), 'moderator', 'owner') configure(alice, room, persistentroom=1) server.crash() # Make the original admission old enough to distinguish it from restart time. checkpoint_clock(server, age=grace - 10) original = checkpoint_clock(server) for _ in range(2): server.restart() probe = server.client('carol') not_joined(probe, room) server.crash() assert checkpoint_clock(server) == original, 'restart extended dormant recovery deadline' checkpoint_clock(server, age=grace + 1) server.restart() alice = server.client('alice') not_joined(alice, room) # Expiration releases nickname reservation; persistent-room ownership remains. carol = server.client('carol') occupant(join(carol, room, 'Alice'), 'participant', 'none') def unknown_component_module(server): server.crash() config = json.loads(server.config.read_text()) marker = 'unimplemented_muc_crash_test_module' config['hosts']['rooms.localhost']['options']['modules_enabled'] = [marker] server.config.write_text(json.dumps(config)) result = subprocess.run([server.binary, 'serve', str(server.config), '--experimental'], capture_output=True, text=True, timeout=10) assert result.returncode != 0, 'unknown MUC component module was accepted' assert marker in result.stdout + result.stderr, result.stdout + result.stderr def destroy_tombstone(server): room = 'destroyed@rooms.localhost' replacement = 'replacement@rooms.localhost' reason = 'Closed after migration' alice, bob = server.client('alice'), server.client('bob') occupant(join(alice, room, 'Alice'), 'moderator', 'owner') configure(alice, room, persistentroom=1) occupant(join(bob, room, 'Bob'), 'participant', 'none') iq(alice, 'destroy', f"" f'{reason}', room) # The IQ result is the durability barrier; no reads or disconnect precede kill. server.crash() journals = list((server.root/'data').rglob('rust_muc_checkpoint.dat')) assert len(journals) == 1, journals checkpoint = journals[0].read_text() for field in ('["destroyed"]=true', f'["reason"]="{reason}"', f'["newjid"]="{replacement}"'): assert field in checkpoint, f'acknowledged tombstone omitted {field}' server.restart() # Neither an old exact identity nor a new account can recreate the room. alice, carol = server.client('alice'), server.client('carol') for client in (alice, carol): client.sync() assert not any(local(packet) == 'presence' and packet.get('from', '').startswith(room + '/') for packet in client.queue), [ET.tostring(packet) for packet in client.queue] denied(join(client, room, 'Alice'), 'gone') def run(binary, selected=()): cases = [('recovery-history', recovery_and_history)] cases += [(name, lambda server, name=name: mutation_barrier(server, name)) for name in ('leave', 'kick', 'demotion', 'password', 'configuration')] cases.append(('restart-grace', restart_grace)) cases.append(('custom-grace', lambda server: restart_grace(server, grace=30))) cases.append(('unknown-module', unknown_component_module)) cases.append(('destroy-tombstone', destroy_tombstone)) for name, test in cases: if selected and name not in selected: continue with tempfile.TemporaryDirectory(prefix='xmpp-muc-crash-' + name + '-') as tmp: options = {'muc_recovery_grace_period': 30} if name == 'custom-grace' else None server = Server(binary, Path(tmp), options) try: test(server) finally: server.close() print('PASS:', name, flush=True) print('PASS: actual STARTTLS SIGKILL MUC recovery, history, acknowledged barriers and fixed grace') if __name__ == '__main__': run(str(Path(sys.argv[1]).resolve()), sys.argv[2:])