zapret-kvn/xray_fluent/platform/windows/win_netinfo.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

425 lines
15 KiB
Python

"""Fast Windows network-adapter queries via ctypes GetAdaptersAddresses.
Replaces PowerShell polling (Get-NetIPAddress / netsh) in TUN readiness
checks: a single GetAdaptersAddresses call takes ~1ms instead of ~500ms
for a PowerShell process, which lets pollers use a 100ms step.
The module imports cleanly on any OS; the actual iphlpapi call is only
attempted on Windows. Any failure raises :class:`WinNetInfoError` (or the
underlying ctypes exception) so callers can fall back to their legacy
PowerShell/netsh paths (Wine and very old Windows builds may misbehave).
"""
from __future__ import annotations
import ctypes
import os
import socket
from ctypes import POINTER, Structure, c_char_p, c_int, c_ubyte, c_ulong, c_ulonglong, c_ushort, c_void_p, c_wchar_p
from dataclasses import dataclass, field
__all__ = [
"AdapterInfo",
"WinNetInfoError",
"adapter_exists",
"adapter_has_ipv4",
"any_adapter_name_contains",
"best_interface_for",
"find_adapter",
"is_available",
"list_adapters",
"resolve_physical_uplink",
"select_physical_uplink",
]
class WinNetInfoError(RuntimeError):
"""Raised when adapter information cannot be obtained via iphlpapi."""
# Windows socket address families (values differ from Linux for AF_INET6).
_AF_UNSPEC = 0
_AF_INET = 2
_AF_INET6 = 23
_GAA_FLAG_SKIP_ANYCAST = 0x0002
_GAA_FLAG_SKIP_MULTICAST = 0x0004
_GAA_FLAG_SKIP_DNS_SERVER = 0x0008
_GAA_FLAG_INCLUDE_ALL_INTERFACES = 0x0100
_ERROR_SUCCESS = 0
_ERROR_BUFFER_OVERFLOW = 111
_ERROR_NO_DATA = 232
# IF_OPER_STATUS: only IfOperStatusUp (1) carries traffic.
_IF_OPER_STATUS_UP = 1
# MAX_ADAPTER_ADDRESS_LENGTH from iptypes.h.
_MAX_ADAPTER_ADDRESS_LENGTH = 8
# IfType values (ipifcons.h) that can never be a physical uplink.
_IF_TYPE_SOFTWARE_LOOPBACK = 24
_IF_TYPE_TUNNEL = 131
# A public IPv4 used only to ask the OS which interface reaches the internet.
_DEFAULT_PROBE_DESTINATION = "8.8.8.8"
class _SOCKADDR(Structure):
# sa_data is sized to cover sockaddr_in6 (28 bytes total).
_fields_ = [("sa_family", c_ushort), ("sa_data", c_ubyte * 26)]
class _SOCKET_ADDRESS(Structure):
_fields_ = [("lpSockaddr", POINTER(_SOCKADDR)), ("iSockaddrLength", c_int)]
class _IP_ADAPTER_UNICAST_ADDRESS(Structure):
pass
# The leading {Length, Flags} pair mirrors the 8-byte alignment union of
# IP_ADAPTER_UNICAST_ADDRESS_LH. The structure is intentionally truncated
# after Address: entries are only ever read through API-allocated memory
# via Next pointers, never allocated or indexed by our sizeof.
_IP_ADAPTER_UNICAST_ADDRESS._fields_ = [
("Length", c_ulong),
("Flags", c_ulong),
("Next", POINTER(_IP_ADAPTER_UNICAST_ADDRESS)),
("Address", _SOCKET_ADDRESS),
]
class _IP_ADAPTER_ADDRESSES(Structure):
pass
# The DNS-server and gateway lists share the {Length, Flags/Reserved, Next,
# Address} prologue with the unicast node, so the same truncated structure
# walks all three chains through API-allocated Next pointers.
#
# Fields through Ipv4Metric mirror IP_ADAPTER_ADDRESSES_LH exactly (iptypes.h);
# the layout is only extended, never reordered, so existing readers that stop
# at FriendlyName keep their offsets. Everything after Ipv4Metric is omitted
# because nothing here reads it. Entries are always API-allocated, so the
# omission is safe: we never allocate or index by our own sizeof.
_IP_ADAPTER_ADDRESSES._fields_ = [
("Length", c_ulong),
("IfIndex", c_ulong),
("Next", POINTER(_IP_ADAPTER_ADDRESSES)),
("AdapterName", c_char_p),
("FirstUnicastAddress", POINTER(_IP_ADAPTER_UNICAST_ADDRESS)),
("FirstAnycastAddress", c_void_p),
("FirstMulticastAddress", c_void_p),
("FirstDnsServerAddress", POINTER(_IP_ADAPTER_UNICAST_ADDRESS)),
("DnsSuffix", c_wchar_p),
("Description", c_wchar_p),
("FriendlyName", c_wchar_p),
("PhysicalAddress", c_ubyte * _MAX_ADAPTER_ADDRESS_LENGTH),
("PhysicalAddressLength", c_ulong),
("Flags", c_ulong),
("Mtu", c_ulong),
("IfType", c_ulong),
("OperStatus", c_int),
("Ipv6IfIndex", c_ulong),
("ZoneIndices", c_ulong * 16),
("FirstPrefix", c_void_p),
("TransmitLinkSpeed", c_ulonglong),
("ReceiveLinkSpeed", c_ulonglong),
("FirstWinsServerAddress", c_void_p),
("FirstGatewayAddress", POINTER(_IP_ADAPTER_UNICAST_ADDRESS)),
("Ipv4Metric", c_ulong),
("Ipv6Metric", c_ulong),
]
@dataclass(slots=True)
class AdapterInfo:
adapter_name: str
friendly_name: str
description: str
if_index: int
ipv4_addresses: list[str]
ipv6_addresses: list[str] # link-local (fe80::/10) excluded
if_type: int = 0
oper_status: int = 0
ipv4_metric: int = 0
ipv4_gateways: list[str] = field(default_factory=list)
dns_servers: list[str] = field(default_factory=list) # IPv4 then IPv6
def _sockaddr_to_ip(sockaddr_ptr) -> tuple[int, str] | None:
if not sockaddr_ptr:
return None
sa = sockaddr_ptr.contents
raw = bytes(sa.sa_data)
family = int(sa.sa_family)
try:
if family == _AF_INET:
# sockaddr_in: sa_data = port(2) + addr(4) + zero padding
return _AF_INET, socket.inet_ntop(socket.AF_INET, raw[2:6])
if family == _AF_INET6:
# sockaddr_in6: sa_data = port(2) + flowinfo(4) + addr(16) + scope
return _AF_INET6, socket.inet_ntop(socket.AF_INET6, raw[6:22])
except (OSError, ValueError):
return None
return None
def _walk_address_nodes(first) -> list[tuple[int, str]]:
"""Collect (family, ip) from a {Length, Flags, Next, Address} node chain."""
collected: list[tuple[int, str]] = []
node = first
while node:
item = node.contents
parsed = _sockaddr_to_ip(item.Address.lpSockaddr)
if parsed is not None:
collected.append(parsed)
node = item.Next
return collected
def _parse_adapter_chain(first) -> list[AdapterInfo]:
adapters: list[AdapterInfo] = []
current = first
while current:
entry = current.contents
ipv4_addresses: list[str] = []
ipv6_addresses: list[str] = []
for family, text in _walk_address_nodes(entry.FirstUnicastAddress):
if family == _AF_INET:
if text != "0.0.0.0":
ipv4_addresses.append(text)
elif not text.lower().startswith("fe80"):
ipv6_addresses.append(text)
ipv4_gateways = [text for family, text in _walk_address_nodes(entry.FirstGatewayAddress)
if family == _AF_INET and text != "0.0.0.0"]
# DNS servers: IPv4 first, then non-link-local IPv6, matching how the
# bootstrap resolver prefers reachable servers.
dns_v4 = [text for family, text in _walk_address_nodes(entry.FirstDnsServerAddress) if family == _AF_INET]
dns_v6 = [text for family, text in _walk_address_nodes(entry.FirstDnsServerAddress)
if family == _AF_INET6 and not text.lower().startswith("fe80")]
raw_name = entry.AdapterName
adapters.append(
AdapterInfo(
adapter_name=(raw_name or b"").decode("ascii", errors="replace"),
friendly_name=str(entry.FriendlyName or ""),
description=str(entry.Description or ""),
if_index=int(entry.IfIndex),
ipv4_addresses=ipv4_addresses,
ipv6_addresses=ipv6_addresses,
if_type=int(entry.IfType),
oper_status=int(entry.OperStatus),
ipv4_metric=int(entry.Ipv4Metric),
ipv4_gateways=ipv4_gateways,
dns_servers=dns_v4 + dns_v6,
)
)
current = entry.Next
return adapters
_get_adapters_addresses = None
def _resolve_get_adapters_addresses():
global _get_adapters_addresses
if _get_adapters_addresses is None:
iphlpapi = ctypes.WinDLL("iphlpapi")
func = iphlpapi.GetAdaptersAddresses
func.argtypes = [c_ulong, c_ulong, c_void_p, c_void_p, POINTER(c_ulong)]
func.restype = c_ulong
_get_adapters_addresses = func
return _get_adapters_addresses
class _SOCKADDR_IN(Structure):
_fields_ = [
("sin_family", c_ushort),
("sin_port", c_ushort),
("sin_addr", c_ubyte * 4),
("sin_zero", c_ubyte * 8),
]
_get_best_interface_ex = None
def _resolve_get_best_interface_ex():
global _get_best_interface_ex
if _get_best_interface_ex is None:
iphlpapi = ctypes.WinDLL("iphlpapi")
func = iphlpapi.GetBestInterfaceEx
func.argtypes = [c_void_p, POINTER(c_ulong)]
func.restype = c_ulong
_get_best_interface_ex = func
return _get_best_interface_ex
def best_interface_for(dest_ipv4: str) -> int:
"""Interface index the OS would use to reach ``dest_ipv4`` (0 on failure).
This is the authoritative default-route decision (the same one sing-box
logs as the ``default interface``). It does not depend on the unreliable
per-adapter gateway list. Passing the AWG server's own IP returns the
physical uplink even while a tunnel holds the default route, because
WireGuard keeps a host route to the server endpoint off the tunnel.
"""
if os.name != "nt":
return 0
try:
func = _resolve_get_best_interface_ex()
except Exception: # missing DLL/symbol (Wine, stripped systems)
return 0
sockaddr = _SOCKADDR_IN()
sockaddr.sin_family = _AF_INET
try:
sockaddr.sin_addr = (c_ubyte * 4)(*socket.inet_aton(dest_ipv4))
except OSError:
return 0
index = c_ulong(0)
if func(ctypes.byref(sockaddr), ctypes.byref(index)) != _ERROR_SUCCESS:
return 0
return int(index.value)
def list_adapters() -> list[AdapterInfo]:
"""Return all network adapters. Raises WinNetInfoError on any failure."""
if os.name != "nt":
raise WinNetInfoError("GetAdaptersAddresses is only available on Windows")
try:
func = _resolve_get_adapters_addresses()
except Exception as exc: # missing DLL/symbol (Wine, stripped systems)
raise WinNetInfoError(f"iphlpapi is unavailable: {exc}") from exc
# Wintun can briefly exist without being bound to either address family
# while sing-box configures it. Ask for every NDIS interface so a poll
# started during that window does not keep overlooking the same adapter.
# DNS servers are kept (no SKIP_DNS_SERVER): the physical-uplink resolver
# needs them for bootstrap resolution, and the extra parsing is cheap.
flags = (
_GAA_FLAG_SKIP_ANYCAST
| _GAA_FLAG_SKIP_MULTICAST
| _GAA_FLAG_INCLUDE_ALL_INTERFACES
)
size = c_ulong(16 * 1024)
for _ in range(4):
buffer = ctypes.create_string_buffer(size.value)
result = func(_AF_UNSPEC, flags, None, ctypes.cast(buffer, c_void_p), ctypes.byref(size))
if result == _ERROR_BUFFER_OVERFLOW:
continue
if result == _ERROR_NO_DATA:
return []
if result != _ERROR_SUCCESS:
raise WinNetInfoError(f"GetAdaptersAddresses failed with code {result}")
first = ctypes.cast(buffer, POINTER(_IP_ADAPTER_ADDRESSES))
return _parse_adapter_chain(first)
raise WinNetInfoError("GetAdaptersAddresses buffer negotiation failed")
def find_adapter(name: str) -> AdapterInfo | None:
"""Find an adapter whose friendly name, description or GUID matches ``name``."""
needle = str(name or "").strip().lower()
if not needle:
return None
for adapter in list_adapters():
candidates = (adapter.friendly_name, adapter.description, adapter.adapter_name)
if any(candidate.strip().lower() == needle for candidate in candidates):
return adapter
return None
def adapter_exists(name: str) -> bool:
return find_adapter(name) is not None
def _has_routable_ipv4(adapter: AdapterInfo) -> bool:
return any(not addr.startswith("169.254.") for addr in adapter.ipv4_addresses)
def select_physical_uplink(adapters: list[AdapterInfo]) -> AdapterInfo | None:
"""Fallback uplink pick when GetBestInterfaceEx is unavailable.
A candidate must be operationally up, carry a routable (non-APIPA) IPv4
address, and not be a loopback or tunnel interface (so the AWG relay never
binds onto its own tunnel). Ties break on the lowest ``Ipv4Metric`` (the OS
route preference), then the lowest interface index for determinism. The
per-adapter gateway list is deliberately NOT used: Windows leaves
``FirstGatewayAddress`` empty on many working uplinks (Hyper-V vEthernet,
some DHCP setups), which previously rejected every adapter.
"""
candidates = [
adapter
for adapter in adapters
if adapter.if_index > 0
and adapter.oper_status == _IF_OPER_STATUS_UP
and adapter.if_type not in (_IF_TYPE_SOFTWARE_LOOPBACK, _IF_TYPE_TUNNEL)
and _has_routable_ipv4(adapter)
]
if not candidates:
return None
return min(candidates, key=lambda a: (a.ipv4_metric, a.if_index))
def resolve_physical_uplink(dest_ipv4: str = _DEFAULT_PROBE_DESTINATION) -> tuple[int, list[str]] | None:
"""Return ``(interface_index, bootstrap_dns)`` for the physical uplink.
Primary path: ask the OS which interface reaches ``dest_ipv4``
(``GetBestInterfaceEx``) — the authoritative default-route decision, which
does not depend on the unreliable per-adapter gateway list. Pass the AWG
server's IP to get the physical uplink even while a tunnel holds the
default route. Fallback: pick by route metric from the adapter list.
Returns ``None`` only when neither path yields a usable index. Raises
:class:`WinNetInfoError` when adapter enumeration itself is unavailable.
"""
adapters = list_adapters()
by_index = {adapter.if_index: adapter for adapter in adapters}
index = best_interface_for(dest_ipv4)
adapter = by_index.get(index) if index > 0 else None
if index > 0 and (
adapter is None
or adapter.if_type not in (_IF_TYPE_SOFTWARE_LOOPBACK, _IF_TYPE_TUNNEL)
):
return index, list(adapter.dns_servers) if adapter is not None else []
uplink = select_physical_uplink(adapters)
if uplink is not None:
return uplink.if_index, list(uplink.dns_servers)
return None
def adapter_has_ipv4(name: str) -> bool:
adapter = find_adapter(name)
return adapter is not None and bool(adapter.ipv4_addresses)
def any_adapter_name_contains(substring: str) -> bool:
needle = str(substring or "").strip().lower()
if not needle:
return False
for adapter in list_adapters():
candidates = (adapter.friendly_name, adapter.description, adapter.adapter_name)
if any(needle in candidate.lower() for candidate in candidates):
return True
return False
_available: bool | None = None
def is_available() -> bool:
"""Whether the ctypes fast path works in this environment (cached probe)."""
global _available
if _available is None:
if os.name != "nt":
_available = False
else:
try:
list_adapters()
_available = True
except Exception:
_available = False
return _available
def _reset_availability_cache_for_tests() -> None:
global _available
_available = None