#!/usr/bin/env python3
"""Real STARTTLS/SASL SM recovery across abrupt and orderly process restarts.
Usage: python3 tests/sm_restart_network.py BINARY
Pending server stanzas are at-least-once: a received but unacknowledged stanza
must replay. This test does not assert exactly-once client stanza processing.
"""
import json
from pathlib import Path
import signal
import socket
import subprocess
import sys
import tempfile
import time
import xml.etree.ElementTree as ET
from _wire_client import Client, local
from sasl2_bind import Client as InlineClient, SASL2
from muc_restart_network import start, stop, join, iq, occupant
SM = 'urn:xmpp:sm:3'
ROOM = 'durable@rooms.localhost'
def enable(client):
client.send(f"")
response = client.take(lambda e: e.tag == f'{{{SM}}}enabled')
assert response.get('resume') == 'true', ET.tostring(response)
assert response.get('id'), ET.tostring(response)
return response.get('id')
def handled(client):
client.send(f"")
return int(client.take(lambda e: e.tag == f'{{{SM}}}a').get('h'))
def ping(client, ident):
client.send(f"")
return client.iq(ident)
def count_output(client):
"""Track received SM-counted stanzas, including queued room broadcasts."""
client.received = 0
original = client.read
def read():
packet = original()
if local(packet) in ('iq', 'presence', 'message'):
client.received += 1
return packet
client.read = read
def resume(port, cert, token, h, clients, user='alice', inline=False, success=True, password='secret'):
request = f""
if inline:
raw = InlineClient(port, cert)
response = raw.auth(request, user=user, password=password)
result = response.find(f'{{{SM}}}' + ('resumed' if success else 'failed'))
assert result is not None, ET.tostring(response)
if success:
assert response.find(f'{{{SASL2}}}authorization-identifier').text == 'alice@localhost/desktop'
# Adapt the TLS socket to the independent stanza parser; inline success
# precedes replay, and the new stream features follow pending stanzas.
client = Client.__new__(Client)
client.sock, client.buf, client.queue = raw.sock, raw.buffer, []
client.jid = 'alice@localhost/desktop'
clients.append(client)
if success:
while b'' not in client.buf:
client.buf += client.sock.recv(65536)
before, after = client.buf.split(b'', 1)
client.buf = before + tail
else:
client = Client(port, cert, user, password, bind=False)
clients.append(client)
client.send(request)
result = client.take(lambda e: e.tag in (f'{{{SM}}}resumed', f'{{{SM}}}failed'))
assert result.tag == f'{{{SM}}}' + ('resumed' if success else 'failed'), ET.tostring(result)
if success:
assert result.get('previd') == token, ET.tostring(result)
return client, result
def run(binary):
with tempfile.TemporaryDirectory(prefix='xmpp-sm-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]
cert = root/'cert.pem'
config = root/'config.json'
cfg = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'],
'c2s_ports': [port], 's2s_ports': [], 'http_ports': [], 'https_ports': [],
'smacks_hibernation_time': 30,
'modules_enabled': ['tls', 'saslauth', 'sasl2', 'sasl2_bind2', 'carbons',
'csi_simple', 'smacks', 'ping', 'disco', 'register']},
'hosts': {'localhost': {'options': {'authentication': 'internal_hashed',
'password_hash': 'SHA-256', 'ssl': {'key': str(root/'key.pem'),
'certificate': str(cert)}}}, 'rooms.localhost': {'component': 'muc', 'options': {}}}}
config.write_text(json.dumps(cfg))
for user in ('alice', 'bob'):
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:
def connect(user='alice', resource='desktop'):
client = Client(port, cert, user, 'secret', resource)
clients.append(client)
return client
def restart(how):
nonlocal server
if how == 'kill':
server.send_signal(signal.SIGKILL)
assert server.wait(timeout=10) == -signal.SIGKILL
else:
stop(server)
for client in clients:
client.close()
clients.clear()
server = start(binary, config, port, log)
try:
server = start(binary, config, port, log)
bob = connect(user='bob')
bob.available()
alice = connect()
token = enable(alice)
ping(alice, 'acknowledged')
alice.send(f"")
ping(alice, 'pending')
# A handled incoming stanza without a response is preserved too.
alice.send("incoming before crash")
bob.body('incoming before crash')
assert handled(alice) == 3
restart('kill')
for user, ident, h in [('bob', token, 1), ('alice', 'unknown-token', 1), ('alice', token, 99)]:
resume(port, cert, ident, h, clients, user=user, success=False)
alice, result = resume(port, cert, token, 1, clients)
assert result.get('h') == '3', ET.tostring(result)
replay = alice.iq('pending')
assert replay.get('id') == 'pending'
alice.send(f"")
assert handled(alice) == 3
assert not alice.queue, [ET.tostring(e) for e in alice.queue]
restart('term')
alice, result = resume(port, cert, token, 2, clients, inline=True)
assert result.get('h') == '3', ET.tostring(result)
ping(alice, 'after-orderly')
assert not alice.queue, [ET.tostring(e) for e in alice.queue]
alice.send(f"")
assert handled(alice) == 4
restart('kill')
alice, result = resume(port, cert, token, 3, clients)
assert result.get('h') == '4', ET.tostring(result)
ping(alice, 'after-second-crash')
assert not alice.queue, [ET.tostring(e) for e in alice.queue]
# Only one transport owns a token: the next claim displaces it.
old = alice
alice, result = resume(port, cert, token, 4, clients)
old.sock.settimeout(2)
assert b'conflict' in old.until(b'')
assert handled(alice) == 5
# Explicitly replacing the same resource invalidates its token.
replacement = connect()
resume(port, cert, token, 4, clients, success=False)
replacement.close()
# SM resumption itself activates recovered MUC membership. No
# fresh available presence or MUC join is sent after restart.
alice = connect(resource='room')
count_output(alice)
token = enable(alice)
occupant(join(alice, ROOM, 'Alice'), 'moderator', 'owner')
iq(alice, 'unlock', f"", ROOM)
bob = connect(user='bob', resource='room')
count_output(bob)
bob_token = enable(bob)
occupant(join(bob, ROOM, 'Bob'), 'participant', 'none')
alice.send(f"before room crash")
alice.body('before room crash')
bob.body('before room crash')
ping(alice, 'room-fence')
ping(bob, 'bob-room-fence')
alice_count, bob_count = alice.received, bob.received
alice.send(f"")
bob.send(f"")
assert handled(alice) == 4
assert handled(bob) == 2
restart('kill')
alice, result = resume(port, cert, token, alice_count, clients)
assert result.get('h') == '4'
# Bob's logical SM session remains joined while his transport
# is offline. Alice's fanout must queue before Bob authenticates.
alice.send(f"after room crash")
assert alice.body('after room crash').get('from') == ROOM + '/Alice'
assert handled(alice) == 5
# Inspect the committed slot before any Bob authentication.
# A history replay on rejoining is not offline SM delivery.
slots = list((root/'data').rglob(bob_token + '.json'))
assert len(slots) == 1, slots
record = json.loads(slots[0].read_text())
assert record['jid'] == 'bob@localhost/room', record
queued = [ET.fromstring(xml) for xml in record['pending']]
assert any(packet.get('from') == ROOM + '/Alice'
and any(local(node) == 'body' and node.text == 'after room crash'
for node in packet)
for packet in queued), ('room fanout missing from Bob durable pending before authentication', record)
bob, result = resume(port, cert, bob_token, bob_count, clients, user='bob')
assert result.get('h') == '2'
packet = bob.body('after room crash')
assert packet.get('from') == ROOM + '/Alice'
assert packet.find('.//{urn:xmpp:delay}delay') is None, ET.tostring(packet)
restart('term')
# Expired detached sessions stay expired through repeated boot.
cfg['global']['smacks_hibernation_time'] = 1
stop(server)
config.write_text(json.dumps(cfg))
server = start(binary, config, port, log)
short = connect(resource='short')
expired = enable(short)
short.close()
time.sleep(1.3)
restart('kill')
resume(port, cert, expired, 0, clients, success=False)
restart('term')
resume(port, cert, expired, 0, clients, success=False)
# Account deletion must deliver its successful IQ response
# before closing the stream and revoke every old resume token.
stop(server)
cfg['global']['smacks_hibernation_time'] = 300
config.write_text(json.dumps(cfg))
server = start(binary, config, port, log)
removed = connect(resource='remove-account')
revoked = enable(removed)
removed.send("")
removed.iq('remove-account')
stop(server)
subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'],
input='new-secret\n', text=True, check=True, capture_output=True)
server = start(binary, config, port, log)
resume(port, cert, revoked, 0, clients, password='new-secret', success=False)
finally:
if server is not None and server.poll() is None:
stop(server)
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: TLS legacy/SASL2 durable SM after SIGKILL/SIGTERM, counters, ack pruning, at-least-once pending replay, owner/token/h rejection, transport/resource conflict, MUC activation/offline fanout, grace expiry and account deletion/recreation revocation')
if __name__ == '__main__':
run(str(Path(sys.argv[1]).resolve()))