补充单库首次安装和邮件协议实机验收器
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-21 00:59:59 +08:00
parent a74e8100d3
commit 22d4a84db7
5 changed files with 428 additions and 23 deletions

View File

@ -0,0 +1,126 @@
"""SQL 实机验收器的范围限制、敏感信息和命名空间恢复回归。"""
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, call, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools'))
import runtime_sql
import runtime_sql_bridge
def settings(kind='mysql'):
return {'kind': kind, 'name': 'smail_runtime_test_123456abcdef',
'user': 'smail_rt_123456abcdef', 'password': 'a' * 40}
class RuntimeSQLTest(unittest.TestCase):
def test_rejects_other_projects_before_any_command(self):
for key, value in [('name', 'smail'), ('name', 'production'),
('name', "smail_runtime_test_123456abcdef'; DROP TABLE user"),
('user', 'root'), ('kind', 'sqlite'), ('password', "a';SELECT 1")]:
with self.subTest(key=key), patch.object(runtime_sql, 'command') as command:
candidate = settings()
candidate[key] = value
with self.assertRaises(AssertionError):
runtime_sql.SQLDatabase(candidate)
command.assert_not_called()
def test_password_is_not_in_command_arguments(self):
for kind, key in [('mysql', 'MYSQL_PWD'), ('postgres', 'PGPASSWORD')]:
with self.subTest(kind=kind), patch.object(runtime_sql, 'command', return_value='0') as command:
database = runtime_sql.SQLDatabase(settings(kind))
database.assert_empty_users()
args, sql, env = command.call_args.args
self.assertNotIn(settings()['password'], ' '.join(args))
self.assertEqual(env[key], settings()['password'])
self.assertIn('COUNT(*)', sql)
def test_driver_error_details_are_not_exposed(self):
failure = subprocess.CompletedProcess([], 1, '', 'sensitive-test-value')
with patch.object(runtime_sql.subprocess, 'run', return_value=failure):
with self.assertRaises(RuntimeError) as caught:
runtime_sql.command(['mysql'], 'SELECT 1')
self.assertNotIn('sensitive-test-value', str(caught.exception))
def test_fixture_grants_only_exact_database_and_locks_account(self):
for kind in ('mysql', 'postgres'):
with self.subTest(kind=kind):
fixture = runtime_sql.SQLFixture(kind)
with patch.object(fixture, 'admin', return_value='0') as admin:
fixture.create()
fixture.lock()
statements = '\n'.join(item.args[0] for item in admin.call_args_list)
self.assertNotIn('DROP ', statements)
self.assertNotIn('*.*', statements)
self.assertNotIn('CREATEDB', statements)
if kind == 'mysql':
self.assertIn(fixture.settings['name'].replace('_', r'\_'), statements)
self.assertIn('ACCOUNT LOCK', statements)
else:
self.assertIn('NOLOGIN', statements)
def test_uncreated_account_does_not_trigger_admin_command(self):
fixture = runtime_sql.SQLFixture('mysql')
with patch.object(fixture, 'admin') as admin:
fixture.lock()
admin.assert_not_called()
def test_nonempty_database_fails_first_install_check(self):
fixture = runtime_sql.SQLFixture('postgres')
with patch.object(fixture, 'admin', return_value='3'):
with self.assertRaises(AssertionError):
fixture.create()
def test_sql_mode_rejects_hidden_second_sqlite_database(self):
database = runtime_sql.SQLDatabase(settings())
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'data/nested').mkdir(parents=True)
(root / 'data/nested/smail-state.db').touch()
with patch.object(database, 'query') as query:
with self.assertRaises(AssertionError):
database.integrity(root)
query.assert_not_called()
def test_sql_mode_requires_configuration_history_in_same_database(self):
database = runtime_sql.SQLDatabase(settings())
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'data').mkdir()
with patch.object(database, 'query', return_value='0'):
with self.assertRaises(AssertionError):
database.integrity(root)
with patch.object(database, 'query', return_value='5'):
self.assertEqual(database.integrity(root)['sqlite_files'], 0)
def test_bridge_rejects_non_database_port_before_opening_socket(self):
with patch.object(runtime_sql_bridge.socket, 'socket') as socket:
with self.assertRaises(AssertionError):
runtime_sql_bridge.DatabaseBridge(1234, 22)
socket.assert_not_called()
def test_bridge_restores_host_namespace_when_bind_fails(self):
handles = []
for descriptor in (11, 12):
handle = MagicMock()
handle.__enter__.return_value = handle
handle.fileno.return_value = descriptor
handles.append(handle)
with patch.object(runtime_sql_bridge.Path, 'readlink', side_effect=[Path('net2'), Path('net1')]), \
patch('builtins.open', side_effect=handles), \
patch.object(runtime_sql_bridge.os, 'setns', create=True) as setns, \
patch.object(runtime_sql_bridge.socketserver.ThreadingTCPServer, '__init__', side_effect=OSError()), \
patch.object(runtime_sql_bridge.threading, 'Thread') as thread:
with self.assertRaises(OSError):
runtime_sql_bridge.DatabaseBridge(1234, 3306)
self.assertEqual(setns.call_args_list, [call(12, 0), call(11, 0)])
thread.assert_not_called()
if __name__ == '__main__':
unittest.main()

View File

@ -185,6 +185,38 @@ def protocol_operation(protocol, context, code):
return invoke
def verify_imap_append(context, code, message_id, body, attachment):
appended_id = 'append-' + message_id
message = EmailMessage()
message['From'] = ADDRESS
message['To'] = ADDRESS
message['Subject'] = '邮件协议追加和删除验收'
message['Message-ID'] = '<' + appended_id + '>'
message.set_content(body)
message.add_attachment(attachment, maintype='text', subtype='plain', filename='验收附件.txt')
client = LocalIMAP(HOST, 993, ssl_context=context, timeout=30)
try:
assert client.login(ADDRESS, code)[0] == 'OK'
assert client.select('INBOX')[0] == 'OK'
assert client.append('INBOX', None, None, message.as_bytes(policy=email_policy.clone(linesep='\r\n')))[0] == 'OK'
status, values = client.response('APPENDUID')
assert status == 'APPENDUID' and len(values) == 1 and values[0]
validity, uid = map(int, values[0].split())
assert 0 < validity <= 2**32 - 1 and 0 < uid <= 2**32 - 1
assert client.noop()[0] == 'OK'
status, contents = client.uid('FETCH', str(uid), '(BODY.PEEK[])')
assert status == 'OK'
raw = next(item[1] for item in contents if isinstance(item, tuple))
verify_message_content(raw, appended_id, body, attachment)
assert client.uid('STORE', str(uid), '+FLAGS.SILENT', r'(\Deleted)')[0] == 'OK'
assert client.uid('EXPUNGE', str(uid))[0] == 'OK'
status, matches = client.uid('SEARCH', None, 'HEADER', 'Message-ID', '"<' + appended_id + '>"')
assert status == 'OK' and not matches[0].split()
finally:
client.logout()
return {'append_uid': True, 'body_attachment': True, 'uid_expunge': True}
def verify_restored(root, context, password, message_id, port):
client = Client(context, port=port)
for _ in range(60):
@ -229,12 +261,18 @@ def main():
parser.add_argument('--proxy', action='store_true')
parser.add_argument('--password-stdin', action='store_true')
parser.add_argument('--restore-message-id')
parser.add_argument('--sql-stdin', action='store_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-', 'smail-review-restore-')) and root.name == 'instance'
password = sys.stdin.readline().strip() if args.password_stdin else secrets.token_urlsafe(9)
assert len(password) == 12
sql_database = None
if args.sql_stdin:
assert args.password_stdin and not args.restore_message_id
from runtime_sql import SQLDatabase
sql_database = SQLDatabase(json.loads(sys.stdin.readline()))
if args.restore_message_id:
assert args.password_stdin
context = ssl.create_default_context(cafile=str(root / 'ssl/runtime.crt'))
@ -258,9 +296,14 @@ def main():
assert Client(port=setup_port).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
if sql_database:
client.success(setup, sql_database.setup_payload())
sql_database.assert_empty_users()
assert not (root / 'data/smail.db').exists()
else:
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
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': ''})
@ -320,18 +363,22 @@ def main():
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,)]
if sql_database:
sql_database.verify_message(message_id, ACCOUNT, body, attachment)
else:
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'] = 'protocol_read'
result['protocol_read'] = verify_protocol_read(context, codes, message_id, body, attachment)
result['imap_append'] = verify_imap_append(context, codes['imap'], message_id, body, attachment)
result['stage'] = 'overload'
rounds = []
result['overload'] = rounds

View File

@ -20,7 +20,8 @@ def run(*args):
def service_state():
return {name: run('systemctl', 'show', name, '--property=MainPID,NRestarts,ActiveState,InvocationID')
for name in ('smail.service', 'nginx.service', 'mysql.service', 'Scert.service')}
for name in ('smail.service', 'nginx.service', 'mysql.service', 'Scert.service',
'fdm-studio.service', 'postgresql@18-main.service')}
def database_integrity(root):
@ -78,7 +79,10 @@ def main():
parser.add_argument('--nginx-template', type=Path)
parser.add_argument('--memory-max-mib', type=int, choices=(128, 192, 256), default=128)
parser.add_argument('--restore', action='store_true')
parser.add_argument('--database-type', choices=('sqlite', 'mysql', 'postgres'), default='sqlite')
args = parser.parse_args()
if args.restore and args.database_type != 'sqlite':
parser.error('SQL 数据库备份恢复需要独立验收,不能套用 SQLite 冷备流程')
assert os.geteuid() == 0 and run('hostname') == 'ikun2'
os.umask(0o027)
live = Path('/opt/smail')
@ -112,6 +116,10 @@ def main():
shutil.copy2(Path(__file__).with_name('runtime_probe.py'), target / 'runtime_probe.py')
(target / 'runtime_probe.py').chmod(0o640)
shutil.chown(target / 'runtime_probe.py', user='root', group='smail')
if args.database_type != 'sqlite':
shutil.copy2(Path(__file__).with_name('runtime_sql.py'), target / 'runtime_sql.py')
(target / 'runtime_sql.py').chmod(0o640)
shutil.chown(target / 'runtime_sql.py', user='root', group='smail')
if args.nginx_template:
shutil.copy2(Path(__file__).with_name('runtime_proxy.py'), target / 'runtime_proxy.py')
(target / 'runtime_proxy.py').chmod(0o640)
@ -136,6 +144,13 @@ def main():
'topology': 'nginx-8001-8002' if args.nginx_template else 'direct-80-443'}
service_env = []
probe_args = []
sql_fixture = None
bridge = None
result['database_type'] = args.database_type
if args.database_type != 'sqlite':
from runtime_sql import SQLFixture
sql_fixture = SQLFixture(args.database_type)
result['sql_database'] = sql_fixture.settings['name']
if args.nginx_template:
from runtime_proxy import prepare_nginx
prepare_nginx(target, root, args.nginx_template)
@ -146,6 +161,9 @@ def main():
started = time.strftime('%Y-%m-%dT%H:%M:%S')
password = secrets.token_urlsafe(9)
try:
if sql_fixture:
sql_fixture.create()
probe_args.append('--sql-stdin')
subprocess.run([
'systemd-run', '--unit=' + unit, '--property=Type=exec',
'--property=User=smail', '--property=Group=smail',
@ -168,6 +186,9 @@ def main():
assert str(Path('/proc/' + pid + '/ns/net').readlink()) != live_net
assert Path('/proc/' + pid + '/root/opt/smail/smail').stat().st_ino == (root / 'smail').stat().st_ino
assert (root / 'smail').stat().st_ino != (live / 'smail').stat().st_ino
if sql_fixture:
from runtime_sql_bridge import DatabaseBridge
bridge = DatabaseBridge(int(pid), sql_fixture.port)
if args.nginx_template:
subprocess.run([
'systemd-run', '--unit=' + proxy_unit, '--property=Type=exec',
@ -198,7 +219,8 @@ def main():
'--property=KillMode=control-group', '--property=TimeoutStopSec=10s',
'python3', str(target / 'runtime_probe.py'), str(root), '--live-net', live_net,
'--password-stdin', *probe_args,
], input=password + '\n', text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=270)
], input=password + '\n' + (json.dumps(sql_fixture.settings) + '\n' if sql_fixture else ''),
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=270)
(target / 'probe.log').write_text(probe.stdout, encoding='utf-8')
result['probe_code'] = probe.returncode
if (target / 'probe-result.json').exists():
@ -210,15 +232,30 @@ def main():
assert database.execute('SELECT COUNT(*) FROM email WHERE msg_id=?', (message_id,)).fetchone()[0] == 0
result['live_message_absent'] = True
finally:
collect_and_stop(result, unit, probe_unit, proxy_unit if args.nginx_template else None)
result['after'] = service_state()
with (live / 'smail').open('rb') as stream:
result['live_unchanged'] = (result['after'] == before
and hashlib.file_digest(stream, 'sha256').hexdigest() == live_sha)
try:
result['integrity'] = database_integrity(root)
except (AssertionError, sqlite3.Error) as error:
result['integrity'] = {'error': type(error).__name__}
collect_and_stop(result, unit, probe_unit, proxy_unit if args.nginx_template else None)
result['after'] = service_state()
with (live / 'smail').open('rb') as stream:
result['live_unchanged'] = (result['after'] == before
and hashlib.file_digest(stream, 'sha256').hexdigest() == live_sha)
try:
result['integrity'] = sql_fixture.integrity(root) if sql_fixture else database_integrity(root)
except (AssertionError, sqlite3.Error, RuntimeError, OSError, ValueError, subprocess.SubprocessError) as error:
result['integrity'] = {'error': type(error).__name__}
finally:
result.setdefault('cleanup_errors', [])
if bridge:
try:
bridge.close()
result['database_bridge_stopped'] = True
except Exception as error:
result['cleanup_errors'].append({'action': 'database_bridge', 'error': type(error).__name__})
if sql_fixture:
try:
sql_fixture.lock()
result['test_database_account_locked'] = True
except Exception as error:
result['cleanup_errors'].append({'action': 'database_account', 'error': type(error).__name__})
(target / 'service.log').write_text(run('journalctl', '-u', unit, '--no-pager'), encoding='utf-8')
kernel = run('journalctl', '-k', '--since', started, '--no-pager')
result['oom_events'] = sum('oom-kill:constraint=' in line for line in kernel.splitlines())
@ -227,7 +264,10 @@ def main():
assert result['live_unchanged'] and result['oom_events'] == 0
assert not result['cleanup_errors'], '测试单元未全部停止,需要核查保留的结果'
assert not result['resource_errors'], '资源证据不完整,需要核查保留的结果'
assert result['integrity'] == {'smail.db': [('ok',)]}
if sql_fixture:
assert result['integrity'].get('sqlite_files') == 0 and result['integrity']['config_records'] > 0
else:
assert result['integrity'] == {'smail.db': [('ok',)]}
if args.restore:
from runtime_restore import verify_backup_restore
restored = verify_backup_restore(root, result, password, args.nginx_template is not None)

128
test/tools/runtime_sql.py Normal file
View File

@ -0,0 +1,128 @@
"""隔离实机验收的 SQL 数据库适配;不删除数据库,不记录连接凭据。"""
import base64
import json
import os
import re
import secrets
import subprocess
def command(args, sql, env=None):
result = subprocess.run(args, input=sql, text=True, capture_output=True,
timeout=30, env=env, encoding='utf-8')
if result.returncode:
# 驱动错误可能包含连接参数,不能直接传播到验收日志。
raise RuntimeError('隔离测试数据库操作失败,请在受保护环境核查')
return result.stdout.strip()
class SQLDatabase:
def __init__(self, settings):
assert set(settings) == {'kind', 'name', 'user', 'password'}
assert settings['kind'] in ('mysql', 'postgres')
assert re.fullmatch(r'smail_runtime_test_[0-9a-f]{12}', settings['name'])
assert re.fullmatch(r'smail_rt_[0-9a-f]{12}', settings['user'])
assert re.fullmatch(r'[A-Za-z0-9_-]{32,64}', settings['password'])
self.settings = settings.copy()
self.kind = settings['kind']
self.port = 3306 if self.kind == 'mysql' else 5432
def setup_payload(self):
return {'step': 'database', 'action': 'set', 'db_type': self.kind,
'db_mode': 'fields', 'db_host': '127.0.0.1', 'db_port': str(self.port),
'db_name': self.settings['name'], 'db_user': self.settings['user'],
'db_password': self.settings['password']}
def query(self, sql):
env = os.environ.copy()
if self.kind == 'mysql':
env['MYSQL_PWD'] = self.settings['password']
args = ['mysql', '--protocol=tcp', '--host=127.0.0.1', '--port=3306',
'--user=' + self.settings['user'], '--database=' + self.settings['name'],
'--default-character-set=utf8mb4', '--batch', '--raw', '--skip-column-names']
else:
env['PGPASSWORD'] = self.settings['password']
env['PGCLIENTENCODING'] = 'UTF8'
args = ['psql', '-X', '-v', 'ON_ERROR_STOP=1', '-At', '--host=127.0.0.1',
'--port=5432', '--username=' + self.settings['user'],
'--dbname=' + self.settings['name']]
return command(args, sql, env)
def assert_empty_users(self):
table = '`user`' if self.kind == 'mysql' else '"user"'
assert self.query('SELECT COUNT(*) FROM ' + table) == '0'
def verify_message(self, message_id, account, body, attachment):
assert re.fullmatch(r'[a-zA-Z0-9@._-]+', message_id)
constructor = 'JSON_OBJECT' if self.kind == 'mysql' else 'json_build_object'
content = self.query("SELECT " + constructor + "('id',id,'text',text,'attachments',attachments)"
" FROM email WHERE msg_id='" + message_id + "'")
rows = content.splitlines()
assert len(rows) == 1
record = json.loads(rows[0])
assert body in record['text']
attachments = record['attachments']
if isinstance(attachments, str):
attachments = json.loads(attachments)
assert len(attachments) == 1 and attachments[0]['Filename'] == '验收附件.txt'
assert base64.b64decode(attachments[0]['Content']) == attachment
table = '`user`' if self.kind == 'mysql' else '"user"'
assert isinstance(record['id'], int) and record['id'] > 0
owners = self.query('SELECT u.account FROM user_email e JOIN ' + table +
' u ON u.id=e.user_id WHERE e.email_id=' + str(record['id']))
assert owners.splitlines() == [account]
def integrity(self, root):
# 配置历史和业务表必须位于所选 SQL 库,运行目录不能悄悄创建 SQLite。
assert not list((root / 'data').rglob('*.db'))
assert not list((root / 'data').rglob('*.sqlite*'))
count = int(self.query('SELECT COUNT(*) FROM config_records'))
assert count > 0
return {'type': self.kind, 'database': self.settings['name'],
'config_records': count, 'sqlite_files': 0}
class SQLFixture(SQLDatabase):
def __init__(self, kind):
token = secrets.token_hex(6)
super().__init__({'kind': kind, 'name': 'smail_runtime_test_' + token,
'user': 'smail_rt_' + token, 'password': secrets.token_urlsafe(30)})
self.account_created = False
def admin(self, sql, database=None):
if self.kind == 'mysql':
args = ['mysql', '--protocol=socket', '--batch', '--skip-column-names']
else:
args = ['runuser', '-u', 'postgres', '--', 'psql', '-X', '-v', 'ON_ERROR_STOP=1', '-At']
if database:
assert database == self.settings['name']
args += ['--dbname=' + database]
return command(args, sql)
def create(self):
name, user, password = (self.settings[key] for key in ('name', 'user', 'password'))
if self.kind == 'mysql':
self.admin("CREATE USER '" + user + "'@'localhost' IDENTIFIED BY '" + password +
"' WITH MAX_USER_CONNECTIONS 24;")
self.account_created = True
self.admin('CREATE DATABASE `' + name + '` CHARACTER SET utf8mb4;')
# 转义授权模式中的下划线,只授权本轮唯一数据库。
self.admin('GRANT ALL ON `' + name.replace('_', r'\_') + "`.* TO '" + user + "'@'localhost';")
count = self.admin("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='" + name + "';")
else:
self.admin('CREATE ROLE ' + user + " LOGIN CONNECTION LIMIT 24 PASSWORD '" + password + "';")
self.account_created = True
self.admin('CREATE DATABASE ' + name + ' OWNER ' + user + ';')
self.admin('GRANT ALL ON SCHEMA public TO ' + user + ';', name)
count = self.admin("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public';", name)
assert count == '0', '首次安装必须使用空数据库'
def lock(self):
if not self.account_created:
return
user = self.settings['user']
if self.kind == 'mysql':
self.admin("ALTER USER '" + user + "'@'localhost' ACCOUNT LOCK;")
else:
self.admin('ALTER ROLE ' + user + ' NOLOGIN;')

View File

@ -0,0 +1,64 @@
"""只向隔离网络提供单个本机数据库端口,不向宿主网络新增监听端口。"""
from contextlib import ExitStack
import os
from pathlib import Path
import select
import socket
import socketserver
import threading
class DatabaseBridge:
def __init__(self, pid, port):
assert isinstance(pid, int) and pid > 1
assert port in (3306, 5432)
namespace = Path('/proc') / str(pid) / 'ns/net'
assert namespace.readlink() != Path('/proc/self/ns/net').readlink()
limit = threading.BoundedSemaphore(24)
class Handler(socketserver.BaseRequestHandler):
def handle(self):
if not limit.acquire(blocking=False):
return
try:
with socket.create_connection(('127.0.0.1', port), timeout=15) as upstream:
streams = (self.request, upstream)
for stream in streams:
stream.settimeout(30)
while True:
ready, _, _ = select.select(streams, [], [], 30)
if not ready:
return
for source in ready:
data = source.recv(65536)
if not data:
return
target = upstream if source is self.request else self.request
target.sendall(data)
except (OSError, TimeoutError):
return
finally:
limit.release()
class Server(socketserver.ThreadingTCPServer):
daemon_threads = True
allow_reuse_address = False
with ExitStack() as stack:
host = stack.enter_context(open('/proc/self/ns/net', 'rb'))
isolated = stack.enter_context(open(namespace, 'rb'))
try:
os.setns(isolated.fileno(), 0)
self.server = Server(('127.0.0.1', port), Handler)
finally:
os.setns(host.fileno(), 0)
# 监听套接字属于隔离网络,后续工作线程在恢复后的宿主网络连接数据库。
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
def close(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
assert not self.thread.is_alive(), '数据库验收桥接未停止'