diff --git a/backend/app/services/network_utils.py b/backend/app/services/network_utils.py index 5afb84148..6ef9293cf 100644 --- a/backend/app/services/network_utils.py +++ b/backend/app/services/network_utils.py @@ -7,10 +7,14 @@ import shutil import socket import struct import subprocess +import sys logger = logging.getLogger(__name__) -# Interfaces to exclude from selection +# Interfaces to exclude from selection (Linux only — Windows adapter names +# don't follow these prefixes and there's no equivalent uniform Windows +# exclude list worth hard-coding; the psutil path filters on address class +# (loopback, link-local) and interface up-state instead). EXCLUDED_INTERFACE_PREFIXES = ("lo", "docker", "br-", "veth", "virbr") # Resolve full path to `ip` command (may not be in PATH for service users) @@ -22,12 +26,86 @@ def _is_excluded(name: str) -> bool: return any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES) +def _get_network_interfaces_psutil() -> list[dict]: + """Windows path: enumerate interfaces via psutil. + + fcntl + ioctl is Linux-only, and the ``ip`` command isn't available + on Windows either, so both Linux code paths return empty here. psutil + is already a Bambuddy dep (``psutil>=6.0.0``) and gives us cross- + platform name + IPv4 + netmask in one call. + + Filters: IPv4 only (matches the Linux path), skip loopback and + link-local (169.254.0.0/16), skip interfaces psutil reports as down. + No name-based exclusion — users on Windows may legitimately want to + bind a VP to a Hyper-V / WSL / Tailscale virtual adapter. + """ + try: + import psutil + except ImportError: + logger.warning("psutil not available, interface detection unavailable on this platform") + return [] + + interfaces = [] + try: + addrs_by_iface = psutil.net_if_addrs() + stats_by_iface = psutil.net_if_stats() + except Exception as e: + logger.error("psutil failed to enumerate interfaces: %s", e) + return [] + + for name, addrs in addrs_by_iface.items(): + stats = stats_by_iface.get(name) + if stats is not None and not stats.isup: + continue + + for addr in addrs: + if addr.family != socket.AF_INET: + continue + ip = addr.address + netmask = addr.netmask + if not ip or not netmask: + continue + + try: + ip_obj = ipaddress.IPv4Address(ip) + except ValueError: + continue + if ip_obj.is_loopback or ip_obj.is_link_local: + continue + + try: + network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False) + except ValueError: + continue + + interfaces.append( + { + "name": name, + "ip": ip, + "netmask": netmask, + "subnet": str(network), + } + ) + # First IPv4 per interface is enough; matches Linux ioctl which + # returns only the primary IP (aliases land via get_all_interface_ips + # on Linux, which has no Windows analogue worth replicating). + break + + return interfaces + + def get_network_interfaces() -> list[dict]: """Get all network interfaces with their IPs and subnets. Returns: List of dicts with name, ip, netmask, subnet, broadcast """ + # Windows has no fcntl and no `ip` binary; the Linux ioctl path below + # raises ImportError on import fcntl. Route to the psutil-based path + # instead. The Linux path stays as-is for behavioural parity. + if sys.platform == "win32": + return _get_network_interfaces_psutil() + interfaces = [] try: