network_utils.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  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(include_excluded: bool = False) -> list[dict]:
  83. """Get all network interfaces with their IPs and subnets.
  84. Args:
  85. include_excluded: keep the interfaces ``EXCLUDED_INTERFACE_PREFIXES``
  86. normally hides. That list exists to keep docker0 and friends out
  87. of the Virtual Printer's bind dropdown; a caller asking about an
  88. address the kernel has already chosen needs the real answer.
  89. Returns:
  90. List of dicts with name, ip, netmask, subnet, broadcast
  91. """
  92. # Only Linux has the SIOCGIFADDR/SIOCGIFNETMASK ioctls + sockaddr layout the
  93. # path below relies on. Windows lacks fcntl entirely; macOS/BSD have fcntl but
  94. # different ioctl numbers, so the ioctl path there fails per-interface and
  95. # returns an empty list (breaking the VP bind-interface dropdown on macOS).
  96. # Route everything non-Linux to the cross-platform psutil path.
  97. if not sys.platform.startswith("linux"):
  98. return _get_network_interfaces_psutil()
  99. interfaces = []
  100. try:
  101. import fcntl
  102. for iface in socket.if_nameindex():
  103. name = iface[1]
  104. # Skip excluded interfaces
  105. if not include_excluded and _is_excluded(name):
  106. continue
  107. try:
  108. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  109. # Get IP address
  110. ip_bytes = fcntl.ioctl(
  111. s.fileno(),
  112. 0x8915, # SIOCGIFADDR
  113. struct.pack("256s", name[:15].encode()),
  114. )[20:24]
  115. ip = socket.inet_ntoa(ip_bytes)
  116. # Get netmask
  117. netmask_bytes = fcntl.ioctl(
  118. s.fileno(),
  119. 0x891B, # SIOCGIFNETMASK
  120. struct.pack("256s", name[:15].encode()),
  121. )[20:24]
  122. netmask = socket.inet_ntoa(netmask_bytes)
  123. # Calculate subnet
  124. network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
  125. interfaces.append(
  126. {
  127. "name": name,
  128. "ip": ip,
  129. "netmask": netmask,
  130. "subnet": str(network),
  131. }
  132. )
  133. s.close()
  134. except OSError:
  135. # Interface doesn't have an IP or other error
  136. pass
  137. except Exception as e:
  138. logger.debug("Error getting info for interface %s: %s", name, e)
  139. except ImportError:
  140. # fcntl not available (Windows)
  141. logger.warning("fcntl not available, interface detection limited")
  142. except Exception as e:
  143. logger.error("Error enumerating interfaces: %s", e)
  144. return interfaces
  145. def get_all_interface_ips(include_excluded: bool = False) -> list[dict]:
  146. """Get all IPs (primary + aliases) for every interface, minus the excluded ones.
  147. Uses `ip -j addr show` to see secondary/alias IPs that ioctl misses.
  148. Falls back to ioctl-based get_network_interfaces() if `ip` is unavailable.
  149. Args:
  150. include_excluded: see :func:`get_network_interfaces`.
  151. Returns:
  152. List of dicts with name, ip, netmask, subnet, is_alias, label
  153. """
  154. if not _IP_CMD:
  155. logger.debug("ip command not found, using ioctl fallback")
  156. return _fallback_get_all_ips(include_excluded)
  157. try:
  158. result = subprocess.run(
  159. [_IP_CMD, "-j", "addr", "show"],
  160. capture_output=True,
  161. text=True,
  162. timeout=5,
  163. )
  164. if result.returncode != 0:
  165. logger.warning("ip addr show failed: %s", result.stderr)
  166. return _fallback_get_all_ips(include_excluded)
  167. interfaces_data = json.loads(result.stdout)
  168. except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:
  169. logger.warning("Failed to run ip -j addr show: %s", e)
  170. return _fallback_get_all_ips(include_excluded)
  171. entries = []
  172. for iface in interfaces_data:
  173. ifname = iface.get("ifname", "")
  174. if not include_excluded and _is_excluded(ifname):
  175. continue
  176. ipv4_count = 0
  177. for addr_info in iface.get("addr_info", []):
  178. if addr_info.get("family") != "inet":
  179. continue
  180. ip = addr_info.get("local", "")
  181. prefix = addr_info.get("prefixlen", 24)
  182. label = addr_info.get("label", ifname)
  183. try:
  184. network = ipaddress.IPv4Network(f"{ip}/{prefix}", strict=False)
  185. netmask = str(network.netmask)
  186. except ValueError:
  187. continue
  188. # An alias has ":" in label (e.g. eth0:vp1) or is not the first IPv4
  189. is_alias = ":" in label or ipv4_count > 0
  190. entries.append(
  191. {
  192. "name": ifname,
  193. "ip": ip,
  194. "netmask": netmask,
  195. "subnet": str(network),
  196. "is_alias": is_alias,
  197. "label": label,
  198. }
  199. )
  200. ipv4_count += 1
  201. # Sort: primary IPs first per interface, then by interface name
  202. entries.sort(key=lambda e: (e["name"], e["is_alias"], e["ip"]))
  203. return entries
  204. def _fallback_get_all_ips(include_excluded: bool = False) -> list[dict]:
  205. """Fallback: wrap get_network_interfaces() result with alias fields."""
  206. return [
  207. {
  208. **iface,
  209. "is_alias": False,
  210. "label": iface["name"],
  211. }
  212. for iface in get_network_interfaces(include_excluded)
  213. ]
  214. def find_local_ipv4_network(local_ip: str) -> ipaddress.IPv4Network | None:
  215. """The IPv4 network configured on the local interface holding ``local_ip``.
  216. An IPv4 address carries no prefix length, so the only way to know how far
  217. a LAN reaches is to read the prefix off the interface that owns the
  218. address. ``None`` means no local interface claims it, which is the honest
  219. answer whenever the platform gives us no interface data at all.
  220. Nothing is filtered: ``local_ip`` is an address the kernel already picked
  221. as a route source, so answering "unknown" because it happens to sit on a
  222. bridge named ``br-something`` would be a worse answer than the truth.
  223. """
  224. try:
  225. address = ipaddress.IPv4Address(local_ip)
  226. except ValueError:
  227. return None
  228. for iface in get_all_interface_ips(include_excluded=True):
  229. if iface.get("ip") != str(address):
  230. continue
  231. try:
  232. return ipaddress.IPv4Network(iface["subnet"], strict=False)
  233. except (KeyError, TypeError, ValueError):
  234. logger.debug("Interface %s has an unusable subnet %r", iface.get("name"), iface.get("subnet"))
  235. return None
  236. return None
  237. def find_interface_for_ip(target_ip: str) -> dict | None:
  238. """Find which interface is on the same subnet as the target IP.
  239. Args:
  240. target_ip: IP address to find the matching interface for
  241. Returns:
  242. Interface dict or None if not found
  243. """
  244. try:
  245. target = ipaddress.IPv4Address(target_ip)
  246. except ValueError:
  247. logger.error("Invalid target IP: %s", target_ip)
  248. return None
  249. interfaces = get_all_interface_ips()
  250. for iface in interfaces:
  251. if iface.get("is_alias"):
  252. continue
  253. try:
  254. network = ipaddress.IPv4Network(iface["subnet"], strict=False)
  255. if target in network:
  256. logger.debug("Found interface %s (%s) for target %s", iface["name"], iface["ip"], target_ip)
  257. return iface
  258. except ValueError:
  259. continue
  260. logger.warning("No interface found for target IP %s", target_ip)
  261. return None
  262. def get_other_interfaces(exclude_ip: str) -> list[dict]:
  263. """Get all interfaces except the one with the given IP.
  264. Args:
  265. exclude_ip: IP address of interface to exclude
  266. Returns:
  267. List of interface dicts
  268. """
  269. interfaces = get_network_interfaces()
  270. return [iface for iface in interfaces if iface["ip"] != exclude_ip]