network_utils.py 9.2 KB

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