zapret-kvn/tests/test_diagnostic_runtime_snapshot.py
loop-uh c624a581ac
Some checks failed
Windows project source guards / test (push) Has been cancelled
Подключение и переключения быстрее: без лишних ожиданий и PowerShell
- Ожидание освобождения TUN-адаптера ждало любой адаптер xftun*, а
  Wintun убитого ядра остаётся в системе выключенным: после первой же
  TUN-сессии каждое отключение и переключение в TUN стояло полные 10 с.
  Теперь ждём только адаптер своей сессии, пока он не перестанет быть Up.
- sing-box останавливается сразу kill(): terminate() консольный sing-box
  на Windows не обрабатывает, 500 мс «мягкой» остановки истекали всегда.
- «Осиротевшие» ядра и winws2 ищутся снимком WinAPI вместо PowerShell+WMI
  (~0,2 с на каждый запуск ядра, у Zapret — дважды); PowerShell — запасной.
- Готовность портов ядер, relay и Clash API сначала смотрится по таблице
  TCP: подключение к ещё закрытому порту Windows держит повторами SYN, и
  ранняя проба всегда выжидала свой таймаут (~0,35–0,5 с).
- sing-box check — только после неудачного запуска, ради понятной ошибки:
  параллельно с работающим ядром она для TUN-конфига мешает туннелю.
- cryptography грузится только при пароле данных и ссылках Happ.

Проверка готовности TUN оставлена прежней: при более ранней готовности
закрепление сервера через Clash API в первые ~0,4 с ломало TCP туннеля
в «родном» режиме sing-box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:41:18 +03:00

149 lines
8.1 KiB
Python
Raw Permalink 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 json
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
import zipfile
from PyQt6.QtCore import QCoreApplication
from xray_fluent.application.controller import AppController
from xray_fluent.diagnostics.runtime_errors import RuntimeErrorJournal, core_failure
from xray_fluent.diagnostics.export import capture_runtime_config, export_diagnostics
from xray_fluent.engines.singbox.manager import SingBoxManager
from xray_fluent.profiles.models import AppState
from xray_fluent.diagnostics.runtime_logging import RuntimeLogContext, RuntimeNodeIdentity
_APP = QCoreApplication.instance() or QCoreApplication([])
class DiagnosticRuntimeSnapshotTests(unittest.TestCase):
def config(self):
return {
'dns': {'servers': [{'tag': 'proxy-dns', 'type': 'udp', 'server': '10.9.0.1', 'detour': 'proxy'}]},
'route': {'final': 'proxy', 'rules': [{'domain_suffix': ['example.com'], 'outbound': 'direct'}]},
'endpoints': [{'type': 'wireguard', 'tag': 'proxy', 'address': ['10.9.0.3/32'],
'private_key': 'PRIVATE-SENTINEL', 'detour': 'awg3-direct',
'amnezia': {'header_protection_key': 'HPK-SENTINEL', 's1': 12},
'peers': [{'public_key': 'PUBLIC-SENTINEL', 'pre_shared_key': 'PSK-SENTINEL'}]}],
'outbounds': [{'type': 'socks', 'username': 'USER-SENTINEL', 'password': 'PASSWORD-SENTINEL'}],
}
def test_snapshot_keeps_routing_but_detaches_and_redacts_credentials(self):
config = self.config()
snapshot = capture_runtime_config(Path('core/sing-box.exe'), config)
self.assertEqual(snapshot['config']['dns'], config['dns'])
self.assertEqual(snapshot['config']['route'], config['route'])
self.assertEqual(snapshot['config']['endpoints'][0]['detour'], 'awg3-direct')
self.assertNotIn('SENTINEL', json.dumps(snapshot))
config['dns']['servers'][0]['server'] = '8.8.8.8'
self.assertEqual(snapshot['config']['dns']['servers'][0]['server'], '10.9.0.1')
self.assertEqual(config['endpoints'][0]['private_key'], 'PRIVATE-SENTINEL')
def test_runtime_redactor_handles_aliases_embedded_uris_and_headers(self):
config = {
'server': 'hy2://AUTH-SENTINEL@example.com:443/',
'uri': 'vless://URI-SENTINEL@example.com',
'tls': {'clientKey': 'TLS-SENTINEL', 'pinSHA256': 'PIN-SENTINEL'},
'settings': {'encryption': 'ENCRYPTION-SENTINEL', 'headers': {'Authorization': 'Bearer HEADER-SENTINEL'}},
'HeaderProtectionKey': 'CAMEL-SENTINEL',
'message': 'header_protection_key=LOG-SENTINEL',
}
snapshot = capture_runtime_config(Path('core/hysteria.exe'), config)
self.assertNotIn('SENTINEL', json.dumps(snapshot))
def test_singbox_captures_actual_written_attempt_even_if_core_check_fails(self):
config = self.config()
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
exe = root / 'sing-box.exe'
exe.touch()
runtime_path = root / 'runtime.json'
manager = SingBoxManager()
with (
patch('xray_fluent.engines.singbox.manager.resolve_configured_path', return_value=exe),
patch('xray_fluent.engines.singbox.manager.RUNTIME_DIR', root),
patch('xray_fluent.engines.singbox.manager.SINGBOX_CONFIG_FILE', runtime_path),
patch('xray_fluent.engines.singbox.manager.check_config', return_value=(False, 'core config rejected')),
):
self.assertFalse(manager.start(str(exe), config))
self.assertEqual(json.loads(runtime_path.read_text()), config)
self.assertEqual(manager.diagnostic_config['config']['dns'], config['dns'])
self.assertNotIn('SENTINEL', json.dumps(manager.diagnostic_config))
def test_controller_export_includes_config_and_session_identity_without_replanning(self):
snapshot = capture_runtime_config(Path('core/sing-box.exe'), self.config())
context = RuntimeLogContext('sing-box', 'front', 'proxy', 9,
selected=RuntimeNodeIdentity('safe-ref', 'AWG', 'awg'))
controller = SimpleNamespace(
state=AppState(), recent_logs=['DNS lookup failed'], connected=True,
runtime_errors=RuntimeErrorJournal(),
_core_log_contexts={'sing-box': context},
singbox=SimpleNamespace(is_running=True, diagnostic_config=snapshot),
xray=SimpleNamespace(is_running=False, diagnostic_config=None),
hysteria=SimpleNamespace(is_running=False, diagnostic_config=None),
)
with tempfile.TemporaryDirectory() as folder:
with patch('xray_fluent.application.controller.LOG_DIR', Path(folder)):
path = AppController.build_diagnostics(controller)
with zipfile.ZipFile(path) as archive:
runtime = json.loads(archive.read('runtime_redacted.json'))
text = '\n'.join(archive.read(name).decode() for name in archive.namelist())
component = runtime['components']['sing-box']
self.assertEqual(component['log_context']['generation'], 9)
self.assertEqual(component['log_context']['selected']['ref'], 'safe-ref')
self.assertTrue(component['running'])
self.assertEqual(component['last_written_config'], snapshot)
self.assertIsNone(runtime['components']['hysteria']['last_written_config'])
self.assertNotIn('SENTINEL', text)
def test_absent_snapshot_is_explicit_and_journal_retains_raw_cause(self):
journal = RuntimeErrorJournal()
journal.record(core_failure('sing-box', 'runtime', 'lookup example.com: context deadline exceeded'))
with tempfile.TemporaryDirectory() as folder:
path = export_diagnostics(Path(folder) / 'diagnostic.zip', AppState(), [], runtime_errors=journal.snapshot())
with zipfile.ZipFile(path) as archive:
runtime = json.loads(archive.read('runtime_redacted.json'))
errors = json.loads(archive.read('runtime_errors.json'))
self.assertFalse(runtime['available'])
self.assertEqual(errors[0]['failure']['message'], 'lookup example.com: context deadline exceeded')
if __name__ == '__main__':
unittest.main()
class SingboxParallelCheckTests(unittest.TestCase):
"""`sing-box check` идёт параллельно с запуском, а не перед ним."""
def _start(self, check_result):
config = {'log': {'level': 'warn'}, 'inbounds': [], 'outbounds': [{'type': 'direct', 'tag': 'direct'}]}
errors: list[str] = []
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
exe = root / 'sing-box.exe'
exe.touch()
manager = SingBoxManager()
manager.error.connect(errors.append)
with (
patch('xray_fluent.engines.singbox.manager.resolve_configured_path', return_value=exe),
patch('xray_fluent.engines.singbox.manager.RUNTIME_DIR', root),
patch('xray_fluent.engines.singbox.manager.SINGBOX_CONFIG_FILE', root / 'runtime.json'),
patch('xray_fluent.engines.singbox.manager._kill_orphaned_blocking', return_value=False),
patch('xray_fluent.engines.singbox.manager.check_config', return_value=check_result) as check,
):
started = manager.start(str(exe), config)
return started, errors, check
def test_failed_launch_reports_core_check_explanation(self):
started, errors, check = self._start((False, 'Ядро не знает поле «Tag»'))
self.assertFalse(started)
self.assertEqual(errors, ['Ядро не знает поле «Tag»'])
check.assert_called_once()
def test_failed_launch_with_valid_config_reports_launch_failure_once(self):
started, errors, _check = self._start((True, ''))
self.assertFalse(started)
self.assertEqual(len(errors), 1)
self.assertIn('sing-box process', errors[0])