#!/usr/bin/env python3 """Personal delivery must not await a stalled external XEP-0357 gateway. Real c2s and s2s STARTTLS; Bob hibernates or goes offline with registered push. The account domain is chat.localhost and the external push service is localhost, which is not a configured local host. Its standard s2s endpoint is a local fixture on 127.0.0.1:5269; the special-use localhost name avoids external DNS. After verified TLS it reads the server's stream but never sends features. --expect-blocked demonstrates the old bug in a bounded two-second observation; the default asserts both durable deliveries and Alice's handled SM ACK while the real gateway transport remains stalled. This is a local regression fixture, not an APNs/FCM or production-client validation. """ import argparse import asyncio import hashlib import json import os import pathlib import shutil import socket import ssl import subprocess import tempfile import time from message_latency_network import slot import muc_sm_load_network as network from muc_sm_load_network import SM, start PUSH = 'urn:xmpp:push:0' GATEWAY = 'localhost' DOMAIN = 'chat.localhost' network.STREAM = network.STREAM.replace("to='localhost'", f"to='{DOMAIN}'") class Client(network.Client): def __init__(self, *args): super().__init__(*args) self.jid = self.jid.replace('@localhost/', f'@{DOMAIN}/') async def fence(self, identifier): await self.stanza(f"") result = await self.wait(lambda: self.iqs.pop(identifier, None), identifier) assert result.get('type') == 'result' def openssl(root, *args): subprocess.run(['openssl', *args], cwd=root, check=True, capture_output=True) def certificates(root): openssl(root, 'req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-days', '1', '-subj', '/CN=Push latency fixture CA', '-addext', 'basicConstraints=critical,CA:TRUE', '-keyout', 'ca.key', '-out', 'ca.pem') openssl(root, 'req', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-subj', '/CN=localhost', '-keyout', 'key.pem', '-out', 'server.csr') (root/'extensions').write_text('basicConstraints=critical,CA:FALSE\n' 'keyUsage=critical,digitalSignature\nextendedKeyUsage=serverAuth,clientAuth\n' 'subjectAltName=DNS:localhost,DNS:chat.localhost\n') openssl(root, 'x509', '-req', '-in', 'server.csr', '-CA', 'ca.pem', '-CAkey', 'ca.key', '-CAcreateserial', '-days', '1', '-extfile', 'extensions', '-out', 'cert.pem') async def poll(predicate, description, timeout=5): deadline = time.monotonic() + timeout while time.monotonic() < deadline: if predicate(): return await asyncio.sleep(.01) raise AssertionError(description) class Blackhole: def __init__(self, root): self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) self.context.load_cert_chain(root/'cert.pem', root/'key.pem') self.seen = asyncio.Event() self.tasks = set() self.writers = set() self.errors = [] self.closed = False self.snapshot = lambda: None self.pending_at_stream = None async def accept(self, reader, writer): self.tasks.add(asyncio.current_task()) self.writers.add(writer) try: stream = await reader.readuntil(b'>') if stream.startswith(b'') assert b'stream:stream' in stream and GATEWAY.encode() in stream header = ("") writer.write((header + "" "").encode()) await writer.drain() assert b'starttls' in await reader.readuntil(b'>') writer.write(b"") await writer.drain() await writer.start_tls(self.context) stream = await reader.readuntil(b'>') if stream.startswith(b'') assert b'stream:stream' in stream and GATEWAY.encode() in stream writer.write(header.encode()) await writer.drain() self.pending_at_stream = self.snapshot() self.seen.set() # Keep transport open; features are never sent and no route completes. await reader.read() self.closed = True except asyncio.CancelledError: raise except Exception as error: self.errors.append(repr(error)) finally: writer.close() self.writers.discard(writer) self.tasks.discard(asyncio.current_task()) async def close(self): for task in list(self.tasks): task.cancel() await asyncio.gather(*self.tasks, return_exceptions=True) for writer in list(self.writers): writer.close() async def exercise(root, binary, config, port, args, log): gateway = Blackhole(root) try: listener = await asyncio.start_server(gateway.accept, '127.0.0.1', 5269) except OSError as error: raise RuntimeError(f'push fixture needs an unused {GATEWAY}:5269: {error}') from error clients = [] server = None try: # rustls-native-certs uses this explicit fixture trust bundle; identity # verification still checks the remote JID's DNS SAN during real TLS. previous = os.environ.get('SSL_CERT_FILE') os.environ['SSL_CERT_FILE'] = str(root/'ca.pem') try: server = start(str(binary), config, port, log) finally: if previous is None: os.environ.pop('SSL_CERT_FILE', None) else: os.environ['SSL_CERT_FILE'] = previous context = ssl.create_default_context(cafile=str(root/'ca.pem')) for number in range(2): client = await asyncio.wait_for(Client.connect(number, port, context, 2), 10) clients.append(client) client.auto_ack = False if number == 0 or args.recipient == 'hibernating': await client.enable() await client.stanza('') await client.fence(f'ready-{number}') if client.token: await client.handled() await client.ack() alice, bob = clients await bob.stanza(f"" f"") response = await bob.wait(lambda: bob.iqs.get('push-enable'), 'push registration') assert response.get('type') == 'result' if bob.token: await bob.handled() await bob.ack() await poll(lambda: bool(slot(root, bob).get('push_identifier')), 'missing durable push binding') else: registration = list((root/'data').rglob('cloud_notify/*.dat')) assert len(registration) == 1 and 'phone' in registration[0].read_text(), 'missing native push registration' await bob.close_raw() mode = 'hibernating' if bob.token else 'removed' await poll(lambda: any('session-disconnected' in line and mode in line and bob.jid in line for line in (root/'server.log').read_text().splitlines()), f'Bob did not enter {mode} state') if not bob.token: assert len(list((root/'data').rglob('rust_smacks/*.json'))) == 1, 'offline Bob unexpectedly has an SM slot' def pending_state(): if bob.token: pending = slot(root, bob)['pending'] else: pending = [path.read_text() for path in (root/'data').rglob('offline/*.list')] return {identifier: any(identifier in stanza for stanza in pending) for identifier in ('push-first', 'push-second')} gateway.snapshot = pending_state before = alice.inputs began = time.perf_counter() for identifier in ('push-first', 'push-second'): await alice.stanza(f"" f"{identifier}") await alice.send(f"") # Observe for the fixed interval even if a faulty implementation emits an # early ACK. Durable pending state and a real blocked gateway are required. await asyncio.sleep(args.bound) present = pending_state() acknowledgements = [int(node.get('h')) for node in alice.controls[f'{{{SM}}}a']] seen_at_bound = gateway.seen.is_set() assert not gateway.errors and not gateway.closed, (gateway.errors, gateway.closed) if not args.expect_blocked or bob.token: assert present['push-first'], 'first message was not durably delivered before push' if args.expect_blocked: assert not present['push-second'], 'old blocking behavior was not reproduced' assert not acknowledgements, acknowledgements assert slot(root, alice)['handled'] == before, 'first message marked handled before blocked push' else: assert present['push-second'], 'second message blocked behind external push' assert acknowledgements == [alice.inputs], acknowledgements assert slot(root, alice)['handled'] == alice.inputs assert not alice.errors, alice.errors assert gateway.seen.is_set(), f'no real post-TLS push attempt: {gateway.errors}' assert not gateway.errors and not gateway.closed, (gateway.errors, gateway.closed) if not args.expect_blocked: assert gateway.pending_at_stream['push-first'], 'push started before first durable recipient delivery' elif not bob.token: assert not present['push-first'], 'old offline ordering bug was not reproduced' assert not gateway.pending_at_stream['push-first'] return {'status': 'reproduced-blocking' if args.expect_blocked else 'pass', 'recipient_state': args.recipient, 'durable_pending_at_gateway_stream': gateway.pending_at_stream, 'observation_seconds': args.bound, 'elapsed_seconds': time.perf_counter()-began, 'gateway_post_tls_stream_observed': gateway.seen.is_set(), 'gateway_post_tls_stream_at_bound': seen_at_bound, 'blocked_stage': 'post-tls-features' if gateway.seen.is_set() else 'external-route-before-tcp', 'durable_pending': present, 'sender_sm_ack_h': acknowledgements, 'sender_inputs': alice.inputs, 'sender_handled_before': before} finally: # Kill before closing the blackhole: releasing it could complete the old # awaited route and would invalidate this bounded reproducer. if server is not None: server.kill() server.wait(timeout=5) listener.close() await listener.wait_closed() await gateway.close() await asyncio.gather(*(client.close_raw() for client in clients), return_exceptions=True) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--binary', default='target/release/rust-xmpp') parser.add_argument('--expect-blocked', action='store_true') parser.add_argument('--recipient', choices=('hibernating', 'offline'), default='hibernating') parser.add_argument('--bound', type=float, default=2) parser.add_argument('--output') args = parser.parse_args() assert 0 < args.bound < 10, 'observation must precede the 30-second federation timeout' with tempfile.TemporaryDirectory(prefix='xmpp-push-latency-') as directory: root = pathlib.Path(directory) binary = root/'rust-xmpp' shutil.copy2(pathlib.Path(args.binary).resolve(), binary) certificates(root) 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', 'smacks', 'ping', 'cloud_notify', 's2s'], 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}, 'hosts': {DOMAIN: {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256'}}}})) for number in range(2): subprocess.run([str(binary), 'create-user', str(config), f'load-{number}', '--password-stdin'], input='load-test-password\n', text=True, check=True, capture_output=True) with (root/'server.log').open('w+') as log: try: report = asyncio.run(exercise(root, binary, config, port, args, log)) except BaseException: log.seek(0) print(log.read()) raise report['binary_version'] = subprocess.run([str(binary), '--version'], check=True, capture_output=True, text=True).stdout.strip() report['binary_sha256'] = hashlib.sha256(binary.read_bytes()).hexdigest() report['scope'] = 'loopback TLS fixture; stalled external XMPP push service; no APNs/FCM' result = json.dumps(report, indent=2)+'\n' if args.output: pathlib.Path(args.output).write_text(result) print(result, end='') if __name__ == '__main__': main()