Files
rust-xmpp/tests/sm_restart_network.py
T
wt 7f60661eef - MUC сохраняет согласованный журнал конфигурации, affiliations, nick,
ролей,
  членства и ограниченной истории без MAM. Изменение фиксируется до
  публикации
  ответа; при ошибке записи состояние комнаты откатывается.
- Crash recovery использует последнее сохранённое поколение после
  SIGKILL,
  включая временные комнаты. Heartbeat обновляет живое членство;
  ожидающие записи сохраняют исходный deadline через повторные
  перезапуски.
  `muc_recovery_grace_period`: по умолчанию 300 секунд, пределы
  1..604800.
- Подтверждённые выходы, смена nick, ролей и политик комнаты не
  заменяются
  старой записью восстановления. Native config/state остаются экспортом
  для
  Prosody; согласованное восстановление Rust использует собственный
  sidecar.
- Исправлены MUC probe, неизвестные типы presence, вход без MUC `<x/>`,
  ошибки
  доставки и статус 333. Обычные presence extensions сохраняются в живом
  обмене; поддельная MUC identity фильтруется. `subject` вместе с `body`
  либо
  `thread` не изменяет тему комнаты.
- Подключены `muc_max_rooms`, `restrict_room_creation`, политики
  регистрации
  и зарегистрированного nick, `muc_tombstone_expiry`; добавлены
  встроенные
  административные ad-hoc команды уничтожения комнат и изменения
  affiliation.
- Hub сохраняет resumable XEP-0198 сессии, счётчики и очередь. Resume
  после
  перезапуска доступен на новом TLS/SASL-транспорте через legacy SASL и
  SASL2.
  Действующие приватные SM-записи восстанавливают логические сессии и
  MUC
  до открытия listeners: offline-доставка попадает в очередь,
  синтетический
  join и история повторно не отправляются. SM resume не требует
  дополнительной
  available presence; без SM восстановление MUC требует новой
  аутентификации
  точного полного JID и available presence либо явного MUC-входа.
  Входящий handled-счётчик обновляется после обработки. Сокеты TCP/TLS
  не сохраняются; неопределённая сетевая отправка допускает повторную
  доставку.
- Известные встроенные MUC-возможности Prosody отображаются на
  Rust-реализацию.
  Произвольные Lua/community-модули не исполняются; неизвестные явно
  включённые
  MUC-модули отклоняются при запуске. Общие и host `modules_disabled`
  учитываются
  для vCard и occupant-id.

Порядок восстановления, ограничения хранения и отката на Prosody:
[docs/MUC-RECOVERY.md](docs/MUC-RECOVERY.md).

Проверка: 293 Rust tests, Clippy/fmt/release, полный сетевой прогон, 500
TLS
MUC+SM ресурсов с SIGKILL/resume и native reference — успешно.
Подробности: [docs/VALIDATION-0.8.0.md](docs/VALIDATION-0.8.0.md).
2026-10-20 17:34:43 +07:00

264 lines
13 KiB
Python

#!/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"<enable xmlns='{SM}' resume='true'/>")
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"<r xmlns='{SM}'/>")
return int(client.take(lambda e: e.tag == f'{{{SM}}}a').get('h'))
def ping(client, ident):
client.send(f"<iq type='get' id='{ident}'><ping xmlns='urn:xmpp:ping'/></iq>")
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"<resume xmlns='{SM}' previd='{token}' h='{h}'/>"
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'</stream:features>' not in client.buf:
client.buf += client.sock.recv(65536)
before, after = client.buf.split(b'<stream:features', 1)
_, tail = after.split(b'</stream:features>', 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"<a xmlns='{SM}' h='1'/>")
ping(alice, 'pending')
# A handled incoming stanza without a response is preserved too.
alice.send("<message to='bob@localhost'><body>incoming before crash</body></message>")
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"<a xmlns='{SM}' h='2'/>")
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"<a xmlns='{SM}' h='3'/>")
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'</stream:stream>')
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"<query xmlns='http://jabber.org/protocol/muc#owner'><x xmlns='jabber:x:data' type='submit'/></query>", ROOM)
bob = connect(user='bob', resource='room')
count_output(bob)
bob_token = enable(bob)
occupant(join(bob, ROOM, 'Bob'), 'participant', 'none')
alice.send(f"<message to='{ROOM}' type='groupchat'><body>before room crash</body></message>")
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"<a xmlns='{SM}' h='{alice_count}'/>")
bob.send(f"<a xmlns='{SM}' h='{bob_count}'/>")
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"<message to='{ROOM}' type='groupchat'><body>after room crash</body></message>")
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("<iq type='set' id='remove-account'><query xmlns='jabber:iq:register'><remove/></query></iq>")
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()))