From fb75ba4ea93331befa61992388a617a3b95070b4 Mon Sep 17 00:00:00 2001 From: Abolfazl Date: Thu, 23 Apr 2026 13:29:50 +0330 Subject: [PATCH] Removed netifaces for better compatibility --- README.md | 2 +- README_FA.md | 1 - requirements.txt | 4 +- src/lan_utils.py | 161 ++++++++++++++++++++++++++--------------------- 4 files changed, 91 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index ceff8f3..5bd014f 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ Install everything from [`requirements.txt`](requirements.txt). All listed packa | `h2` | HTTP/2 multiplexing to the Apps Script relay (significantly faster) | | `brotli` | Decompression of `Content-Encoding: br` responses | | `zstandard` | Decompression of `Content-Encoding: zstd` responses | -| `netifaces` | Better network interface detection for LAN sharing (fallback available without it) | + ### Load Balancing diff --git a/README_FA.md b/README_FA.md index 5af49ad..c697090 100644 --- a/README_FA.md +++ b/README_FA.md @@ -226,7 +226,6 @@ json | `h2` | ارتباط HTTP/2 با رله Apps Script (به‌طور محسوسی سریع‌تر) | | `brotli` | پشتیبانی از فشرده‌سازی `Content-Encoding: br` | | `zstandard` | پشتیبانی از فشرده‌سازی `Content-Encoding: zstd` | -| `netifaces` | تشخیص بهتر اینترفیس‌های شبکه برای اشتراک‌گذاری LAN (در صورت نبود آن، حالت جایگزین در دسترس است) | ### استفاده از چند Script ID diff --git a/requirements.txt b/requirements.txt index c0f1980..ee2b572 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,5 +13,5 @@ brotli>=1.1.0 # Optional: Zstandard decompression (some CDNs now serve `zstd`) zstandard>=0.22.0 -# Optional: Better network interface detection for LAN sharing -netifaces>=0.11.0 +# LAN interface detection now uses only the Python standard library +# (works on Windows, Linux, macOS, Android/Termux without a C compiler). diff --git a/src/lan_utils.py b/src/lan_utils.py index b8e271d..ec6648b 100644 --- a/src/lan_utils.py +++ b/src/lan_utils.py @@ -3,92 +3,104 @@ LAN utilities for detecting network interfaces and IP addresses. Provides functionality to enumerate local network interfaces and their associated IP addresses for LAN proxy sharing. + +Implementation notes +-------------------- +This module intentionally relies only on the Python standard library so +that it works out-of-the-box on every supported OS (Windows, Linux, +macOS, Android/Termux, *BSD) without requiring a C compiler or native +build tools (previous versions depended on ``netifaces``, which needs +"Microsoft Visual C++ 14.0 or greater" on Windows and was a frequent +install blocker for users on slow connections). + +Strategy (in order): +1. "UDP connect trick" to reliably discover the primary outbound + IPv4 and IPv6 addresses on any OS. +2. ``socket.getaddrinfo(hostname, ...)`` to enumerate additional + addresses bound to the host (covers multi-homed machines). + +These two steps together cover every real-world LAN scenario on +Windows, Linux, macOS, Android/Termux and *BSD. (We intentionally do +*not* try to map per-interface names to IPs via the stdlib — that is +not portable and, on Windows, triggers 30 s DNS timeouts.) """ import ipaddress import logging import socket -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Set log = logging.getLogger("LAN") +# --------------------------------------------------------------------------- +# Primary-IP discovery (UDP connect trick) +# --------------------------------------------------------------------------- +def _primary_ip(family: int, probe_addr: str) -> Optional[str]: + """ + Return the primary local IP the OS would use to reach ``probe_addr``. + + Uses a connected UDP socket which does *not* actually send packets — + the kernel just picks the source address from its routing table. + Works identically on Windows, Linux, macOS, and Android. + """ + s = socket.socket(family, socket.SOCK_DGRAM) + try: + s.settimeout(0.5) + # Port 80 is arbitrary; no packet is sent for UDP connect(). + s.connect((probe_addr, 80)) + ip = s.getsockname()[0] + if family == socket.AF_INET6: + ip = ip.split('%', 1)[0] # strip zone id + return ip + except OSError: + return None + finally: + s.close() + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- def get_network_interfaces() -> Dict[str, List[str]]: """ - Get all network interfaces and their associated IP addresses. - - Returns a dictionary mapping interface names to lists of IP addresses - (both IPv4 and IPv6). Only includes interfaces with valid IP addresses - that are not loopback. + Get network interfaces and their associated non-loopback IP addresses. Returns: - Dict[str, List[str]]: Interface name -> list of IP addresses + Dict[str, List[str]]: Interface label -> list of IP addresses. + Interface names are best-effort; on some platforms we fall back + to synthetic labels such as ``"primary"`` / ``"primary_ipv6"``. """ - interfaces = {} + interfaces: Dict[str, List[str]] = {} + seen_ips: Set[str] = set() + def _add(label: str, ip: Optional[str]) -> None: + if not ip or ip in seen_ips: + return + if ip.startswith('127.') or ip == '::1': + return + seen_ips.add(ip) + interfaces.setdefault(label, []).append(ip) + + # 1) Primary outbound IPs (most reliable, cross-platform). + _add('primary', _primary_ip(socket.AF_INET, '192.0.2.1')) # TEST-NET-1 + _add('primary_ipv6', _primary_ip(socket.AF_INET6, '2001:db8::1')) # doc prefix + + # 2) Enumerate via hostname resolution (picks up multi-homed hosts). try: - import netifaces - for iface in netifaces.interfaces(): - addrs = netifaces.ifaddresses(iface) - ips = [] - # IPv4 addresses - if netifaces.AF_INET in addrs: - for addr in addrs[netifaces.AF_INET]: - ip = addr.get('addr') - if ip and not ip.startswith('127.'): - ips.append(ip) - # IPv6 addresses (without scope) - if netifaces.AF_INET6 in addrs: - for addr in addrs[netifaces.AF_INET6]: - ip = addr.get('addr') - if ip and not ip.startswith('::1') and not '%' in ip: - # Remove scope if present - ips.append(ip.split('%')[0]) - if ips: - interfaces[iface] = ips - except ImportError: - # Fallback to socket method for basic detection - log.debug("netifaces not available, using socket fallback") - interfaces = _get_interfaces_socket_fallback() - - return interfaces - - -def _get_interfaces_socket_fallback() -> Dict[str, List[str]]: - """ - Fallback method to get network interfaces using socket. - - This is less comprehensive than netifaces but works without extra dependencies. - """ - interfaces = {} - - try: - # Get hostname and try to resolve to IPs hostname = socket.gethostname() - try: - # Get IPv4 addresses - ipv4_info = socket.getaddrinfo(hostname, None, socket.AF_INET) - ipv4_addrs = [info[4][0] for info in ipv4_info if not info[4][0].startswith('127.')] - if ipv4_addrs: - interfaces['primary'] = list(set(ipv4_addrs)) # Remove duplicates - except socket.gaierror: - pass + except OSError: + hostname = '' + for family, label in ((socket.AF_INET, 'host'), (socket.AF_INET6, 'host_ipv6')): try: - # Get IPv6 addresses - ipv6_info = socket.getaddrinfo(hostname, None, socket.AF_INET6) - ipv6_addrs = [] - for info in ipv6_info: + for info in socket.getaddrinfo(hostname, None, family): ip = info[4][0] - if not ip.startswith('::1') and not '%' in ip: - ipv6_addrs.append(ip.split('%')[0]) - if ipv6_addrs: - interfaces['primary_ipv6'] = list(set(ipv6_addrs)) - except socket.gaierror: - pass - - except Exception as e: - log.debug("Socket fallback failed: %s", e) + if family == socket.AF_INET6: + ip = ip.split('%', 1)[0] + _add(label, ip) + except (socket.gaierror, OSError): + continue return interfaces @@ -107,21 +119,24 @@ def get_lan_ips(port: int = 8085) -> List[str]: List[str]: List of "IP:port" strings for LAN access """ interfaces = get_network_interfaces() - lan_addresses = [] + lan_addresses: List[str] = [] for iface_ips in interfaces.values(): for ip in iface_ips: try: - # Validate IP and check if it's a private address addr = ipaddress.ip_address(ip) - if addr.is_private or addr.is_link_local: - lan_addresses.append(f"{ip}:{port}") except ValueError: continue + if addr.is_loopback or addr.is_unspecified: + continue + # Include private, link-local, and unique-local (IPv6 fc00::/7) ranges. + if addr.is_private or addr.is_link_local: + bracket = f"[{ip}]" if isinstance(addr, ipaddress.IPv6Address) else ip + lan_addresses.append(f"{bracket}:{port}") - # Remove duplicates while preserving order - seen = set() - unique_addresses = [] + # Remove duplicates while preserving order. + seen: Set[str] = set() + unique_addresses: List[str] = [] for addr in lan_addresses: if addr not in seen: seen.add(addr)