v0.6.6 still failed AWG startup with "Physical interface for Amnezia UDP
transport not found" on the common Hyper-V vEthernet setup. Live probing on
real Windows showed the cause: GetAdaptersAddresses leaves FirstGatewayAddress
empty on working uplinks (Hyper-V vEthernet, some DHCP configs), so the
gateway-based selector rejected every adapter and returned None. (Interface
index, type, metric, addresses and DNS all parsed correctly — only the gateway
list was unreliable.)
Resolve the uplink with GetBestInterfaceEx instead: ask the OS which interface
reaches a destination — the authoritative default-route decision, independent
of the gateway list. Pass the AWG server's own IP so the physical uplink is
returned even while a tunnel holds the default route (WireGuard keeps a host
route to the server off the tunnel). The metric-based selector remains as a
fallback, now excluding tunnel/loopback by IfType instead of requiring a
gateway. Verified on real Windows: resolve_physical_uplink() -> (10, [dns]).
Also:
- Hysteria/Amnezia no longer raise a user-facing warning when the background
DoH health probes (1.1.1.1/8.8.8.8/9.9.9.9) fail on a censored exit: the
server handshake is already authenticated and real traffic flows, so the
probe failure is a false alarm. The diagnostic log is kept.
- AWG startup failures now reach the info bar. Previously _on_amnezia_failure
returned early when no amnezia session was committed yet, so a failed AWG
start surfaced nothing to the user ("just exits").
892 unit tests pass (offscreen). End-to-end AWG connect still needs on-device
confirmation on the Hyper-V machine.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HAjvZYzPW2yTtToKJXGdbS
119 lines
5.7 KiB
Python
119 lines
5.7 KiB
Python
"""Post-front diagnostics cannot undo an authenticated, HTTPS-proven transport."""
|
|
from concurrent.futures import Future
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest.mock import Mock, patch
|
|
from PyQt6.QtCore import QProcess
|
|
from PyQt6.QtWidgets import QApplication
|
|
from xray_fluent.engines.amnezia.manager import AmneziaManager
|
|
from xray_fluent.application.controller import AppController
|
|
|
|
_APP = QApplication.instance() or QApplication([])
|
|
|
|
|
|
class AmneziaHealthTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.manager = AmneziaManager()
|
|
self.addCleanup(self.manager.deleteLater)
|
|
self.addCleanup(self.manager._cancel_health)
|
|
self.manager._running = True
|
|
self.manager.stats = {'peers': [{'last_handshake_time_sec': 123}], 'https_check': 'passed'}
|
|
process = patch.object(self.manager._process, 'state', return_value=QProcess.ProcessState.Running)
|
|
process.start(); self.addCleanup(process.stop)
|
|
self.errors, self.warnings = [], []
|
|
self.manager.error.connect(self.errors.append)
|
|
self.manager.warning.connect(self.warnings.append)
|
|
|
|
def start_check(self):
|
|
futures = []
|
|
executor = Mock()
|
|
def submit(*args, **kwargs):
|
|
future = Future(); futures.append(future); return future
|
|
executor.submit.side_effect = submit
|
|
with patch('xray_fluent.engines.amnezia.manager.ThreadPoolExecutor', return_value=executor):
|
|
self.assertTrue(self.manager.verify_front_dns({'listen': '127.0.0.1:11819', 'username': 'u', 'password': 'p'}))
|
|
self.assertEqual(len(futures), 3)
|
|
return futures
|
|
|
|
def start_transport_check(self):
|
|
futures = []
|
|
executor = Mock()
|
|
def submit(*args, **kwargs):
|
|
future = Future(); futures.append(future); return future
|
|
executor.submit.side_effect = submit
|
|
with patch('xray_fluent.engines.amnezia.manager.ThreadPoolExecutor', return_value=executor):
|
|
self.assertTrue(self.manager._ready(11819, {'username': 'u', 'password': 'p'}))
|
|
executor.shutdown.assert_not_called()
|
|
self.assertEqual(len(futures), 3)
|
|
return futures, executor
|
|
|
|
def test_authenticated_handshake_survives_all_initial_https_timeouts(self):
|
|
futures, executor = self.start_transport_check()
|
|
for future in futures:
|
|
future.set_exception(TimeoutError('timed out'))
|
|
self.manager._health.poll()
|
|
self.assertTrue(self.manager.is_running)
|
|
self.assertTrue(self.manager.stats['remote_authenticated'])
|
|
self.assertEqual(self.manager.stats['https_check'], 'warning')
|
|
self.assertEqual(self.errors, [])
|
|
# An authenticated tunnel whose DoH probes are censored must not raise a
|
|
# user-facing warning (the handshake already proves the connection).
|
|
self.assertEqual(len(self.warnings), 0)
|
|
executor.shutdown.assert_called_once_with(wait=False, cancel_futures=True)
|
|
|
|
def test_front_check_is_queued_without_parallel_duplicate_wave(self):
|
|
futures, _ = self.start_transport_check()
|
|
config = {'listen': '127.0.0.1:11819', 'username': 'u', 'password': 'p'}
|
|
with patch.object(self.manager, '_start_front_health') as start:
|
|
self.assertTrue(self.manager.verify_front_dns(config))
|
|
start.assert_not_called()
|
|
futures[0].set_result(None)
|
|
self.manager._health.poll()
|
|
start.assert_called_once_with(config)
|
|
self.assertEqual(self.manager.stats['https_check'], 'passed')
|
|
|
|
def test_stop_discards_queued_front_check_and_old_probe_warning(self):
|
|
futures, _ = self.start_transport_check()
|
|
self.manager.verify_front_dns({'listen': '127.0.0.1:11819', 'username': 'u', 'password': 'p'})
|
|
self.manager._cancel_health()
|
|
for future in futures:
|
|
future.set_exception(TimeoutError('old'))
|
|
with patch.object(self.manager, '_start_front_health') as start:
|
|
self.manager._health.poll()
|
|
start.assert_not_called()
|
|
self.assertEqual(self.warnings, [])
|
|
self.assertIsNone(self.manager._pending_front_config)
|
|
self.assertEqual(self.manager.stats['front_dns_check'], 'cancelled')
|
|
|
|
def test_domain_dns_failure_keeps_proven_transport_and_warns_once(self):
|
|
futures = self.start_check()
|
|
for future in futures:
|
|
future.set_exception(OSError('SOCKS CONNECT rejected (reply=4)'))
|
|
self.manager._health.poll()
|
|
self.manager._health.poll()
|
|
self.assertTrue(self.manager.is_running)
|
|
self.assertEqual(self.errors, [])
|
|
self.assertEqual(len(self.warnings), 1)
|
|
self.assertEqual(self.manager.stats['front_dns_check'], 'warning')
|
|
self.assertEqual(self.manager.stats['https_check'], 'passed')
|
|
|
|
def test_success_and_cancel_do_not_warn(self):
|
|
futures = self.start_check()
|
|
futures[1].set_result(None)
|
|
self.manager._health.poll()
|
|
self.assertEqual(self.manager.stats['front_dns_check'], 'passed')
|
|
futures = self.start_check()
|
|
self.manager._cancel_health()
|
|
for future in futures:
|
|
future.set_exception(TimeoutError('old attempt'))
|
|
self.manager._health.poll()
|
|
self.assertEqual(self.warnings, [])
|
|
self.assertEqual(self.manager.stats['front_dns_check'], 'cancelled')
|
|
|
|
def test_stale_warning_cannot_affect_replacement(self):
|
|
ctrl = SimpleNamespace(amnezia=object(), status=Mock())
|
|
AppController._on_amnezia_warning(ctrl, self.manager, 'old')
|
|
ctrl.status.emit.assert_not_called()
|
|
ctrl.amnezia = self.manager
|
|
AppController._on_amnezia_warning(ctrl, self.manager, 'warning')
|
|
ctrl.status.emit.assert_called_once_with('warning', 'warning')
|