Smail/test/tools/runtime_probe.py
smail b47f366435
Some checks are pending
Security Checks / security (push) Waiting to run
修复邮件协议认证错误分类并补齐真实响应验收
2026-09-17 13:05:39 +08:00

269 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""仅在隔离网络中通过正式接口验收首次安装和认证,不持久化测试密码。"""
import argparse
import base64
from concurrent.futures import ThreadPoolExecutor
from email.message import EmailMessage
import http.client
from http.cookies import SimpleCookie
import imaplib
import json
from pathlib import Path
import poplib
import secrets
import smtplib
import socket
import sqlite3
import ssl
import threading
import time
HOST = 'mail.runtime.invalid'
ACCOUNT = 'reviewadmin'
ADDRESS = ACCOUNT + '@runtime.invalid'
class LocalHTTPS(http.client.HTTPSConnection):
def connect(self):
raw = socket.create_connection(('127.0.0.1', self.port), self.timeout)
self.sock = self._context.wrap_socket(raw, server_hostname=self.host)
class Client:
def __init__(self, context=None):
self.context = context
self.cookies = SimpleCookie()
def request(self, path, payload=None, method='POST'):
connection = (LocalHTTPS(HOST, 443, timeout=30, context=self.context)
if self.context else http.client.HTTPConnection('127.0.0.1', 80, timeout=30))
origin = 'https://' + HOST if self.context else 'http://127.0.0.1'
headers = {'Origin': origin, 'Content-Type': 'application/json'}
if self.cookies:
headers['Cookie'] = '; '.join(item.key + '=' + item.coded_value for item in self.cookies.values())
if 'smail_csrf' in self.cookies:
headers['X-CSRF-Token'] = self.cookies['smail_csrf'].value
try:
connection.request(method, path, json.dumps(payload) if payload is not None else None, headers)
response = connection.getresponse()
for name, value in response.getheaders():
if name.lower() == 'set-cookie':
self.cookies.load(value)
content = response.read()
data = json.loads(content) if 'application/json' in response.getheader('Content-Type', '') else None
return response.status, data, response.getheader('Retry-After')
finally:
connection.close()
def success(self, path, payload=None, method='POST'):
status, data, _ = self.request(path, payload, method)
assert status == 200 and data is not None and data.get('errorNo') == 0, (
path, status, data.get('errorNo') if data else None, data.get('errorMsg') if data else None)
return data['data']
def smtp_connect(context):
client = smtplib.SMTP('127.0.0.1', 25, timeout=30)
client.ehlo('runtime.invalid')
assert client.has_extn('starttls') and not client.has_extn('auth')
code, _ = client.docmd('AUTH', 'PLAIN')
assert code in (502, 523, 538), '明文认证未被拒绝'
client._host = HOST
client.starttls(context=context)
client.ehlo('runtime.invalid')
return client
class LocalIMAP(imaplib.IMAP4_SSL):
def _create_socket(self, timeout):
raw = socket.create_connection(('127.0.0.1', self.port), timeout)
return self.ssl_context.wrap_socket(raw, server_hostname=self.host)
class LocalPOP(poplib.POP3_SSL):
def _create_socket(self, timeout):
raw = socket.create_connection(('127.0.0.1', self.port), timeout)
return self.context.wrap_socket(raw, server_hostname=self.host)
def protocol_operation(protocol, context, code):
if protocol == 'smtp':
client = smtp_connect(context)
def invoke():
try:
status, _ = client.auth('PLAIN', lambda challenge=None: '\0' + ADDRESS + '\0' + code)
assert status == 235
return 'success'
except smtplib.SMTPAuthenticationError as error:
assert error.smtp_code == 454, ('smtp', error.smtp_code)
assert b'Temporary authentication failure' in error.smtp_error
return 'busy'
finally:
client.close()
elif protocol == 'imap':
client = LocalIMAP(HOST, 993, ssl_context=context, timeout=30)
def invoke():
try:
status, _ = client.login(ADDRESS, code)
assert status == 'OK'
return 'success'
except imaplib.IMAP4.error as error:
assert 'UNAVAILABLE' in str(error), 'IMAP 未返回临时不可用'
return 'busy'
finally:
client.shutdown()
else:
client = LocalPOP(HOST, 995, context=context, timeout=30)
client.user(ADDRESS)
def invoke():
try:
assert client.pass_(code).startswith(b'+OK')
return 'success'
except poplib.error_proto as error:
assert '[SYS/TEMP]' in str(error), 'POP3 未返回临时不可用'
return 'busy'
finally:
client.close()
return invoke
def main():
parser = argparse.ArgumentParser()
parser.add_argument('root', type=Path)
parser.add_argument('--live-net', required=True)
args = parser.parse_args()
assert str(Path('/proc/self/ns/net').readlink()) != args.live_net, '禁止在宿主网络执行测试'
root = args.root.resolve(strict=True)
assert root.parent.name.startswith('smail-review-runtime-') and root.name == 'instance'
result = {'stage': 'setup', 'passed': False}
started = time.monotonic()
try:
client = Client()
for _ in range(60):
try:
assert client.request('/', method='GET')[0] == 200
break
except (OSError, AssertionError):
time.sleep(1)
else:
raise AssertionError('初始化服务未就绪')
assert 'smail_setup_token' in client.cookies, '初始化令牌未下发'
assert client.cookies['smail_setup_token']['httponly']
assert Client().request('/api/v1/setup', {'step': 'database', 'action': 'get'})[0] == 403
assert not (root / 'data/smail.db').exists(), '业务库必须从空数据开始'
setup = '/api/v1/setup'
client.success(setup, {'step': 'database', 'action': 'set', 'db_type': 'sqlite', 'db_dsn': ''})
with sqlite3.connect(f'file:{root / "data/smail.db"}?mode=ro', uri=True) as database:
assert database.execute('SELECT COUNT(*) FROM user').fetchone()[0] == 0
password = secrets.token_urlsafe(9)
client.success(setup, {'step': 'password', 'action': 'set', 'account': ACCOUNT, 'password': password})
client.success(setup, {'step': 'domain', 'action': 'set', 'smtp_domain': 'runtime.invalid',
'web_domain': HOST, 'multi_domain': ''})
client.success(setup, {'step': 'ssl', 'action': 'set', 'ssl_type': '1',
'key_path': '/opt/smail/ssl/runtime.key', 'crt_path': '/opt/smail/ssl/runtime.crt'})
context = ssl.create_default_context(cafile=str(root / 'ssl/runtime.crt'))
assert context.check_hostname and context.verify_mode == ssl.CERT_REQUIRED
client = Client(context)
for _ in range(60):
try:
assert client.request('/api/ping', method='GET')[0] == 200
break
except (OSError, AssertionError):
time.sleep(1)
else:
raise AssertionError('正式 HTTPS 服务未就绪')
login = {'account': ACCOUNT, 'password': password}
identity = client.success('/api/v1/login', login)
assert identity.get('account') == ACCOUNT and not identity.get('pending')
assert 'smail_csrf' in client.cookies
assert client.success('/api/v1/user/info').get('account') == ACCOUNT
result['setup'] = {'empty_database': True, 'token_required': True, 'login': True, 'session': True}
result['stage'] = 'protocols'
codes = {}
for protocol in ('smtp', 'imap', 'pop3'):
base = '/api/v1/security/credentials/protocols/'
client.success(base + 'enabled', {'protocol': protocol, 'enabled': True}, 'PUT')
codes[protocol] = client.success(base + 'generate', {'protocol': protocol})['code']
assert protocol_operation(protocol, context, codes[protocol])() == 'success'
result['protocol_login'] = list(codes)
with smtp_connect(context) as smtp:
try:
smtp.auth('PLAIN', lambda challenge=None: '\0' + ADDRESS + '\0invalid-code')
raise AssertionError('SMTP 错误凭据未被拒绝')
except smtplib.SMTPAuthenticationError as error:
assert error.smtp_code == 535 and error.smtp_error.startswith(b'5.7.8 ')
result['smtp_invalid_credentials'] = 535
result['stage'] = 'smtp'
message = EmailMessage()
message['From'] = 'probe@runtime.invalid'
message['To'] = ADDRESS
message['Subject'] = '隔离首次安装中文收信验收'
message_id = 'runtime-' + root.parent.name + '@runtime.invalid'
message['Message-ID'] = '<' + message_id + '>'
body = '内部25端口真实收信,检查中文正文和附件。'
attachment = '隔离验收附件内容'.encode('utf-8')
message.set_content(body)
message.add_attachment(attachment, maintype='text', subtype='plain', filename='验收附件.txt')
with smtp_connect(context) as smtp:
assert not smtp.send_message(message)
with sqlite3.connect(f'file:{root / "data/smail.db"}?mode=ro', uri=True) as database:
rows = database.execute('SELECT id,text,attachments FROM email WHERE msg_id=?', (message_id,)).fetchall()
assert len(rows) == 1 and body in rows[0][1]
attachments = json.loads(rows[0][2])
assert len(attachments) == 1 and attachments[0]['Filename'] == '验收附件.txt'
assert base64.b64decode(attachments[0]['Content']) == attachment
owners = database.execute('SELECT u.account FROM user_email e JOIN user u ON u.id=e.user_id WHERE e.email_id=?',
(rows[0][0],)).fetchall()
assert owners == [(ACCOUNT,)]
result['smtp'] = {'message_id': message_id, 'stored': True, 'owners': 1, 'attachment': True}
result['stage'] = 'overload'
rounds = []
result['overload'] = rounds
for protocol in codes:
round_result = {'protocol': protocol, 'responses': [], 'recovered': False}
rounds.append(round_result)
operations = [protocol_operation(protocol, context, codes[protocol]) for _ in range(4)]
barrier = threading.Barrier(8, timeout=20)
def http_operation():
status, data, retry = Client(context).request('/api/v1/login', login)
if status == 503:
assert retry == '1' and data.get('data', {}).get('code') == 'AUTH_BUSY'
return 'busy'
assert status == 200 and data.get('errorNo') == 0 and data['data'].get('account') == ACCOUNT
return 'success'
def execute(operation):
barrier.wait()
began = time.monotonic()
return {'outcome': operation(), 'seconds': round(time.monotonic() - began, 3)}
with ThreadPoolExecutor(max_workers=8) as pool:
for response in pool.map(execute, operations + [http_operation] * 4):
round_result['responses'].append(response)
responses = round_result['responses']
assert any(item['outcome'] == 'busy' for item in responses[:4]), protocol + ' 未观察到繁忙响应'
assert any(item['outcome'] == 'busy' for item in responses[4:]), 'HTTP 未观察到繁忙响应'
assert any(item['outcome'] == 'success' for item in responses), '所有请求均失败'
assert protocol_operation(protocol, context, codes[protocol])() == 'success'
assert client.success('/api/v1/login', login).get('account') == ACCOUNT
round_result['recovered'] = True
(root.parent / 'probe-result.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
result['stage'] = 'complete'
result['passed'] = True
finally:
result['seconds'] = round(time.monotonic() - started, 2)
(root.parent / 'probe-result.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()