#!/usr/bin/env python3 """Measure real TLS/SASL2 Bind2 reconnects with a large personal MAM history. Usage: python3 tests/bind2_archive_latency_network.py --binary BINARY \ --records 100000 --output REPORT.json Seeds valid native records before startup, then checks exact first/last metadata on every fresh connection. Latency measurements have no performance threshold. The executable is copied before use so concurrent rebuilds cannot replace it. """ import argparse import datetime import hashlib import json from pathlib import Path import shutil import socket import statistics import subprocess import tempfile import time import xml.etree.ElementTree as ET from sasl2_bind import BIND2, SASL2, Client MAM = 'urn:xmpp:mam:2' BASE_TIME = 1767225600 def stamp(seconds): return datetime.datetime.fromtimestamp(seconds, datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') def distribution(values): ordered = sorted(values) return {'samples_ms': values, 'median_ms': statistics.median(values), 'p95_ms': ordered[min(len(ordered) - 1, int(len(ordered) * .95))], 'max_ms': ordered[-1]} def exercise(binary, root, records, samples): with socket.socket() as probe: probe.bind(('127.0.0.1', 0)) port = probe.getsockname()[1] subprocess.run(['openssl', 'req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-days', '1', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost', '-addext', 'basicConstraints=critical,CA:FALSE', '-keyout', str(root/'key.pem'), '-out', str(root/'cert.pem')], check=True, capture_output=True) 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', 'sasl2', 'sasl2_bind2', 'mam', 'carbons', 'csi_simple', 'smacks'], 'default_archive_policy': True, 'archive_expires_after': 'never'}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}}}}})) subprocess.run([str(binary), 'create-user', str(config), 'alice', '--password-stdin'], input='secret\n', text=True, check=True, capture_output=True) archive = root/'data'/'localhost'/'archive'/'alice.list' archive.parent.mkdir(parents=True, exist_ok=True) with archive.open('w') as output: for number in range(records): output.write("item({name='message';attr={xmlns='jabber:client';type='chat';" f"from='bob@localhost';stamp='{stamp(BASE_TIME + number)}'" + "};" f"key='seed-{number}';when={BASE_TIME + number};with='bob@localhost';" "{name='body';attr={};'seed personal history'}});\n") expected = {'start': {'id': 'seed-0', 'timestamp': stamp(BASE_TIME)}, 'end': {'id': f'seed-{records - 1}', 'timestamp': stamp(BASE_TIME + records - 1)}} archive_bytes = archive.stat().st_size index_before_start = archive.with_suffix('.lidx').exists() assert not index_before_start, 'seed unexpectedly has a native index' auth_ms, connect_ms, metadata_ms = [], [], [] metadata_samples, iq_metadata_samples = [], [] with (root/'server.log').open('w+') as log: server = subprocess.Popen([str(binary), 'serve', str(config), '--experimental'], stdout=log, stderr=log) try: deadline = time.monotonic() + 30 while True: assert server.poll() is None, 'server stopped before readiness' try: with socket.create_connection(('127.0.0.1', port), timeout=.1): break except OSError: assert time.monotonic() < deadline, 'server did not start' time.sleep(.05) for sample in range(samples): # Each sample reconnects and authenticates the same account. # Client verifies the advertised SASL2/Bind2 inline features. connected = time.perf_counter() client = Client(port, root/'cert.pem') client.sock.settimeout(30) try: begun = time.perf_counter() response = client.auth(f"HistoryReconnect") arrived = time.perf_counter() auth_ms.append((arrived - begun) * 1000) connect_ms.append((arrived - connected) * 1000) jid = response.find('{' + SASL2 + '}authorization-identifier') assert jid is not None and jid.text.startswith('alice@localhost/HistoryReconnect/'), 'missing bound JID' bound = response.find('{' + BIND2 + '}bound') assert bound is not None, 'missing Bind2 bound result' metadata = bound.find('{' + MAM + '}metadata') assert metadata is not None, 'missing MAM metadata' actual = {} for edge in ('start', 'end'): node = metadata.find('{' + MAM + '}' + edge) assert node is not None, f'missing MAM {edge}' actual[edge] = dict(node.attrib) assert actual == expected, (actual, expected) assert len(list(metadata)) == 2, 'unexpected MAM metadata children' metadata_samples.append(actual) # Session is immediately bound; the post-success features # must not ask this client to perform legacy resource bind. assert b'xmpp-bind' not in client.until(b'') query_id = f'metadata-{sample}' begun = time.perf_counter() client.send(f"") iq = ET.fromstring(client.until(b'')) metadata_ms.append((time.perf_counter() - begun) * 1000) assert iq.get('type') == 'result' and iq.get('id') == query_id, 'metadata IQ failed' iq_metadata = iq.find('{' + MAM + '}metadata') assert iq_metadata is not None, 'metadata IQ payload missing' iq_actual = {} for edge in ('start', 'end'): node = iq_metadata.find('{' + MAM + '}' + edge) assert node is not None, f'missing metadata IQ {edge}' iq_actual[edge] = dict(node.attrib) assert iq_actual == expected, (iq_actual, expected) assert len(list(iq_metadata)) == 2, 'unexpected metadata IQ children' iq_metadata_samples.append(iq_actual) finally: client.close() assert archive.stat().st_size == archive_bytes, 'reconnect modified seeded history' finally: server.terminate() try: server.wait(timeout=5) except subprocess.TimeoutExpired: server.kill() server.wait() return {'status': 'pass', 'seeded_personal_archive_records': records, 'archive_bytes': archive_bytes, 'archive_expires_after': 'never', 'index_present_before_start': index_before_start, 'reconnect_samples': samples, 'expected_metadata': expected, 'observed_metadata': metadata_samples, 'mam_metadata_iq': distribution(metadata_ms), 'observed_iq_metadata': iq_metadata_samples, 'sasl2_bind2_success': distribution(auth_ms), 'tcp_starttls_to_bind2_success': distribution(connect_ms)} def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--binary', required=True) parser.add_argument('--records', type=int, default=100000) parser.add_argument('--samples', type=int, default=10) parser.add_argument('--output') args = parser.parse_args() assert args.records > 0 and args.samples > 0 source = Path(args.binary).resolve() with tempfile.TemporaryDirectory(prefix='xmpp-bind2-archive-latency-') as directory: root = Path(directory) binary = root/'rust-xmpp' shutil.copy2(source, binary) report = {'binary_source': str(source), 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), 'scope': 'real loopback TCP/STARTTLS/SASL2 Bind2; isolated personal MAM fixture; no production latency claim', 'timing': 'raw ordered samples; auth timer starts before authenticate and ends at parsed success; full timer includes TCP/STARTTLS/features; metadata IQ timer runs from send to parsed result', 'result': exercise(binary, root, args.records, args.samples)} text = json.dumps(report, indent=2) + '\n' if args.output: Path(args.output).write_text(text) print(text, end='') if __name__ == '__main__': main()