network_utils.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. """Network utility functions for interface detection."""
  2. import ipaddress
  3. import json
  4. import logging
  5. import shutil
  6. import socket
  7. import struct
  8. import subprocess
  9. import sys
  10. logger = logging.getLogger(__name__)
  11. # Interfaces to exclude from selection (Linux only — Windows adapter names
  12. # don't follow these prefixes and there's no equivalent uniform Windows
  13. # exclude list worth hard-coding; the psutil path filters on address class
  14. # (loopback, link-local) and interface up-state instead).
  15. EXCLUDED_INTERFACE_PREFIXES = ("lo", "docker", "br-", "veth", "virbr")
  16. # Resolve full path to `ip` command (may not be in PATH for service users)
  17. _IP_CMD: str | None = shutil.which("ip") or shutil.which("ip", path="/usr/sbin:/sbin:/usr/bin:/bin")
  18. def _is_excluded(name: str) -> bool:
  19. """Check if an interface name should be excluded."""
  20. return any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES)
  21. def _get_network_interfaces_psutil() -> list[dict]:
  22. """Non-Linux path (Windows, macOS, BSD): enumerate interfaces via psutil.
  23. The ioctl request numbers in the Linux path (SIOCGIFADDR 0x8915,
  24. SIOCGIFNETMASK 0x891B) and the sockaddr layout they return are
  25. Linux-specific. On macOS/BSD ``fcntl`` still imports, so those ioctls
  26. don't raise ImportError — they raise ``OSError`` per interface and the
  27. Linux path silently returns an empty list (no VP bind interfaces).
  28. Windows has no ``fcntl``/``ip`` at all. psutil is already a Bambuddy dep
  29. (``psutil>=6.0.0``) and gives cross-platform name + IPv4 + netmask in one
  30. call, so we use it for everything that isn't Linux.
  31. Filters: IPv4 only (matches the Linux path), skip loopback and
  32. link-local (169.254.0.0/16), skip interfaces psutil reports as down.
  33. No name-based exclusion — users may legitimately want to bind a VP to a
  34. Hyper-V / WSL / Tailscale / utun virtual adapter.
  35. """
  36. try:
  37. import psutil
  38. except ImportError:
  39. logger.warning("psutil not available, interface detection unavailable on this platform")
  40. return []
  41. interfaces = []
  42. try:
  43. addrs_by_iface = psutil.net_if_addrs()
  44. stats_by_iface = psutil.net_if_stats()
  45. except Exception as e:
  46. logger.error("psutil failed to enumerate interfaces: %s", e)
  47. return []
  48. for name, addrs in addrs_by_iface.items():
  49. stats = stats_by_iface.get(name)
  50. if stats is not None and not stats.isup:
  51. continue
  52. for addr in addrs:
  53. if addr.family != socket.AF_INET:
  54. continue
  55. ip = addr.address
  56. netmask = addr.netmask
  57. if not ip or not netmask:
  58. continue
  59. try:
  60. ip_obj = ipaddress.IPv4Address(ip)
  61. except ValueError:
  62. continue
  63. if ip_obj.is_loopback or ip_obj.is_link_local:
  64. continue
  65. try:
  66. network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
  67. except ValueError:
  68. continue
  69. interfaces.append(
  70. {
  71. "name": name,
  72. "ip": ip,
  73. "netmask": netmask,
  74. "subnet": str(network),
  75. }
  76. )
  77. # First IPv4 per interface is enough; matches Linux ioctl which
  78. # returns only the primary IP (aliases land via get_all_interface_ips
  79. # on Linux, which has no Windows analogue worth replicating).
  80. break
  81. return interfaces
  82. def get_network_interfaces() -> list[dict]:
  83. """Get all network interfaces with their IPs and subnets.
  84. Returns:
  85. List of dicts with name, ip, netmask, subnet, broadcast
  86. """
  87. # Only Linux has the SIOCGIFADDR/SIOCGIFNETMASK ioctls + sockaddr layout the
  88. # path below relies on. Windows lacks fcntl entirely; macOS/BSD have fcntl but
  89. # different ioctl numbers, so the ioctl path there fails per-interface and
  90. # returns an empty list (breaking the VP bind-interface dropdown on macOS).
  91. # Route everything non-Linux to the cross-platform psutil path.
  92. if not sys.platform.startswith("linux"):
  93. return _get_network_interfaces_psutil()
  94. interfaces = []
  95. try:
  96. import fcntl
  97. for iface in socket.if_nameindex():
  98. name = iface[1]
  99. # Skip excluded interfaces
  100. if _is_excluded(name):
  101. continue
  102. try:
  103. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  104. # Get IP address
  105. ip_bytes = fcntl.ioctl(
  106. s.fileno(),
  107. 0x8915, # SIOCGIFADDR
  108. struct.pack("256s", name[:15].encode()),
  109. )[20:24]
  110. ip = socket.inet_ntoa(ip_bytes)
  111. # Get netmask
  112. netmask_bytes = fcntl.ioctl(
  113. s.fileno(),
  114. 0x891B, # SIOCGIFNETMASK
  115. struct.pack("256s", name[:15].encode()),
  116. )[20:24]
  117. netmask = socket.inet_ntoa(netmask_bytes)
  118. # Calculate subnet
  119. network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
  120. interfaces.append(
  121. {
  122. "name": name,
  123. "ip": ip,
  124. "netmask": netmask,
  125. "subnet": str(network),
  126. }
  127. )
  128. s.close()
  129. except OSError:
  130. # Interface doesn't have an IP or other error
  131. pass
  132. except Exception as e:
  133. logger.debug("Error getting info for interface %s: %s", name, e)
  134. except ImportError:
  135. # fcntl not available (Windows)
  136. logger.warning("fcntl not available, interface detection limited")
  137. except Exception as e:
  138. logger.error("Error enumerating interfaces: %s", e)
  139. return interfaces
  140. def get_all_interface_ips() -> list[dict]:
  141. """Get all IPs (primary + aliases) for all non-excluded interfaces.
  142. Uses `ip -j addr show` to see secondary/alias IPs that ioctl misses.
  143. Falls back to ioctl-based get_network_interfaces() if `ip` is unavailable.
  144. Returns:
  145. List of dicts with name, ip, netmask, subnet, is_alias, label
  146. """
  147. if not _IP_CMD:
  148. logger.debug("ip command not found, using ioctl fallback")
  149. return _fallback_get_all_ips()
  150. try:
  151. result = subprocess.run(
  152. [_IP_CMD, "-j", "addr", "show"],
  153. capture_output=True,
  154. text=True,
  155. timeout=5,
  156. )
  157. if result.returncode != 0:
  158. logger.warning("ip addr show failed: %s", result.stderr)
  159. return _fallback_get_all_ips()
  160. interfaces_data = json.loads(result.stdout)
  161. except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:
  162. logger.warning("Failed to run ip -j addr show: %s", e)
  163. return _fallback_get_all_ips()
  164. entries = []
  165. for iface in interfaces_data:
  166. ifname = iface.get("ifname", "")
  167. if _is_excluded(ifname):
  168. continue
  169. ipv4_count = 0
  170. for addr_info in iface.get("addr_info", []):
  171. if addr_info.get("family") != "inet":
  172. continue
  173. ip = addr_info.get("local", "")
  174. prefix = addr_info.get("prefixlen", 24)
  175. label = addr_info.get("label", ifname)
  176. try:
  177. network = ipaddress.IPv4Network(f"{ip}/{prefix}", strict=False)
  178. netmask = str(network.netmask)
  179. except ValueError:
  180. continue
  181. # An alias has ":" in label (e.g. eth0:vp1) or is not the first IPv4
  182. is_alias = ":" in label or ipv4_count > 0
  183. entries.append(
  184. {
  185. "name": ifname,
  186. "ip": ip,
  187. "netmask": netmask,
  188. "subnet": str(network),
  189. "is_alias": is_alias,
  190. "label": label,
  191. }
  192. )
  193. ipv4_count += 1
  194. # Sort: primary IPs first per interface, then by interface name
  195. entries.sort(key=lambda e: (e["name"], e["is_alias"], e["ip"]))
  196. return entries
  197. def _fallback_get_all_ips() -> list[dict]:
  198. """Fallback: wrap get_network_interfaces() result with alias fields."""
  199. return [
  200. {
  201. **iface,
  202. "is_alias": False,
  203. "label": iface["name"],
  204. }
  205. for iface in get_network_interfaces()
  206. ]
  207. def find_interface_for_ip(target_ip: str) -> dict | None:
  208. """Find which interface is on the same subnet as the target IP.
  209. Args:
  210. target_ip: IP address to find the matching interface for
  211. Returns:
  212. Interface dict or None if not found
  213. """
  214. try:
  215. target = ipaddress.IPv4Address(target_ip)
  216. except ValueError:
  217. logger.error("Invalid target IP: %s", target_ip)
  218. return None
  219. interfaces = get_all_interface_ips()
  220. for iface in interfaces:
  221. if iface.get("is_alias"):
  222. continue
  223. try:
  224. network = ipaddress.IPv4Network(iface["subnet"], strict=False)
  225. if target in network:
  226. logger.debug("Found interface %s (%s) for target %s", iface["name"], iface["ip"], target_ip)
  227. return iface
  228. except ValueError:
  229. continue
  230. logger.warning("No interface found for target IP %s", target_ip)
  231. return None
  232. def get_other_interfaces(exclude_ip: str) -> list[dict]:
  233. """Get all interfaces except the one with the given IP.
  234. Args:
  235. exclude_ip: IP address of interface to exclude
  236. Returns:
  237. List of interface dicts
  238. """
  239. interfaces = get_network_interfaces()
  240. return [iface for iface in interfaces if iface["ip"] != exclude_ip]