zapret-kvn/tests/test_hysteria_authenticated_readiness.py
loop-uh 8d6a05b8d8 fix: resolve AWG uplink via GetBestInterfaceEx and stop false HTTPS warnings
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
2026-09-14 13:13:16 +03:00

141 lines
7.7 KiB
Python

"""A confirmed Hysteria handshake survives blocked external probe addresses."""
import unittest
from concurrent.futures import Future
from types import SimpleNamespace
from unittest.mock import Mock, patch
from PyQt6.QtCore import QCoreApplication, QProcess
from xray_fluent.application.controller import AppController
from xray_fluent.engines.hysteria.manager import HysteriaManager
from xray_fluent.engines.hysteria.runtime_contract import HysteriaFailureCode
from xray_fluent.diagnostics.export import collect_runtime_diagnostics
_APP = QCoreApplication.instance() or QCoreApplication([])
CONNECTED = ('2026-09-06T14:07:47+03:00\tINFO\tconnected to server\t'
'{"addr":"192.0.2.1:443","udpEnabled":true,"tx":0,"ech":false,"count":1}')
class AuthenticatedReadinessTests(unittest.TestCase):
def setUp(self):
self.manager = HysteriaManager()
self.manager._begin_attempt(None, {})
self.manager._starting = True
self.addCleanup(self.manager.deleteLater)
self.addCleanup(self.manager._cancel_health)
self.errors, self.warnings, self.logs = [], [], []
self.manager.error.connect(self.errors.append)
self.manager.warning.connect(self.warnings.append)
self.manager.log_received.connect(self.logs.append)
self.process = patch.object(self.manager._process, 'state', return_value=QProcess.ProcessState.Running)
self.process.start(); self.addCleanup(self.process.stop)
def start_with_pending_checks(self):
clock, pending = [0.0], []
executor = Mock()
def submit(*args, **kwargs):
future = Future(); pending.append(future); return future
executor.submit.side_effect = submit
def pump(seconds):
clock[0] += seconds
self.manager._emit_process_line(CONNECTED)
with patch('xray_fluent.engines.hysteria.manager.ThreadPoolExecutor', return_value=executor), \
patch('xray_fluent.engines.hysteria.manager.time.monotonic', side_effect=lambda: clock[0]), \
patch('xray_fluent.engines.hysteria.manager.sleep_with_events', side_effect=pump):
self.assertTrue(self.manager._wait_until_remote_ready(11809, username='u', password='p'))
self.assertLess(clock[0], 0.2, 'an authenticated connection must not wait for probe timeouts')
self.assertEqual(len(pending), 3)
self.manager._starting = False
self.manager._mark_running()
return pending, executor
def test_authenticated_connection_survives_all_https_timeouts(self):
pending, executor = self.start_with_pending_checks()
for future in pending:
future.set_exception(TimeoutError('_ssl.c:993: The handshake operation timed out'))
self.manager._poll_health()
self.assertTrue(self.manager.is_running)
self.assertEqual(self.errors, [])
# The server handshake is authenticated, so censored DoH probe timeouts
# must not raise a user-facing warning (only the diagnostic log below).
self.assertEqual(len(self.warnings), 0)
self.assertEqual(self.manager.stats, {'remote_authenticated': True, 'https_check': 'warning'})
self.assertTrue(any('stage=health_check' in line and 'TimeoutError' in line for line in self.logs))
executor.shutdown.assert_called_once_with(wait=False, cancel_futures=True)
self.manager._poll_health()
self.assertEqual(len(self.warnings), 0)
def test_one_success_completes_health_without_warning(self):
pending, executor = self.start_with_pending_checks()
pending[0].set_exception(TimeoutError('blocked'))
pending[1].set_result(None)
self.manager._poll_health()
self.assertEqual(self.manager.stats['https_check'], 'passed')
self.assertEqual(self.warnings, [])
executor.shutdown.assert_called_once_with(wait=False, cancel_futures=True)
def test_old_probe_results_cannot_warn_after_stop_or_replace(self):
pending, _ = self.start_with_pending_checks()
self.manager._clear_compatibility_state()
for future in pending:
future.set_exception(TimeoutError('old attempt'))
self.manager._poll_health()
self.assertEqual(self.warnings, [])
self.assertEqual(self.manager.stats['https_check'], 'cancelled')
self.manager._begin_attempt(None, {})
self.assertFalse(self.manager.stats['remote_authenticated'])
def test_listener_and_malformed_logs_are_not_handshake_proof(self):
for line in (
CONNECTED.replace('connected to server', 'SOCKS5 server listening'),
CONNECTED.replace('\tINFO\t', '\tWARN\t'),
CONNECTED.replace('"count":1', '"count":0'),
CONNECTED.replace('"count":1', '"count":true'),
CONNECTED.replace('"ech":false', '"ech":<hidden>'),
):
self.manager._emit_process_line(line)
self.assertFalse(self.manager._remote_authenticated)
def test_handshake_does_not_override_certificate_rejection(self):
self.manager._emit_process_line(CONNECTED)
self.manager._emit_process_line('no certificate matches the pinned hash')
with patch.object(self.manager, '_probe_remote_endpoint') as probe:
self.assertFalse(self.manager._wait_until_remote_ready(11809, username='u', password='p'))
probe.assert_not_called()
self.assertEqual(self.manager.last_failure_code, HysteriaFailureCode.TARGET_PIN_MISMATCH)
def test_probe_destination_refusal_is_not_a_transport_failure(self):
self.start_with_pending_checks()
self.manager._emit_process_line('2026-09-06T14:07:48+03:00\tWARN\tSOCKS5 TCP error\t'
'{"reqAddr":"1.1.1.1:443","error":"connection refused"}')
self.assertEqual(self.errors, [])
self.assertTrue(self.manager.is_running)
self.assertIn('stage=health_check', self.logs[-1])
self.manager._emit_process_line('2026-09-06T14:07:48+03:00\tWARN\tSOCKS5 TCP error\t'
'{"reqAddr":"1.1.1.1:443","error":"no certificate matches the pinned hash"}')
self.assertEqual(self.manager.last_failure_code, HysteriaFailureCode.TARGET_PIN_MISMATCH)
self.assertEqual(len(self.errors), 1)
def test_diagnostics_distinguish_authentication_and_https_health(self):
self.manager.stats = {'remote_authenticated': True, 'https_check': 'warning'}
ctrl = SimpleNamespace(hysteria=self.manager, connected=True, _core_log_contexts={})
snapshot = collect_runtime_diagnostics(ctrl)
self.assertEqual(snapshot['components']['hysteria']['transport_stats'], self.manager.stats)
def test_health_warning_is_logged_without_a_core_failure_episode(self):
ctrl = SimpleNamespace(_core_log_contexts={}, _transition_generation=1,
state=SimpleNamespace(settings=SimpleNamespace(tun_mode=False)),
_record_core_failure=Mock(), _log=Mock())
line = '[hysteria][attempt=1 stage=health_check] WARNING: HTTPS timeout'
AppController._on_core_log(ctrl, 'hysteria', line)
ctrl._record_core_failure.assert_not_called()
ctrl._log.assert_called_once()
AppController._on_core_log(ctrl, 'hysteria', '[hysteria][attempt=1 stage=remote_handshake] ERROR authentication failed')
ctrl._record_core_failure.assert_called_once()
def test_warning_from_retired_manager_does_not_reach_ui(self):
current = SimpleNamespace(process_generation=5)
ctrl = SimpleNamespace(hysteria=current, _hysteria_active_generation=5, status=Mock())
AppController._on_hysteria_warning(ctrl, SimpleNamespace(process_generation=4), 'old')
ctrl.status.emit.assert_not_called()
AppController._on_hysteria_warning(ctrl, current, 'health warning')
ctrl.status.emit.assert_called_once_with('warning', 'health warning')