network_utils.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  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 _psutil_ipv4_entries(exclude_by_name: bool = False) -> list[dict]:
  22. """Every bindable IPv4 address psutil reports, one entry per address.
  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. Secondary addresses are included. psutil returns every unicast address
  32. bound to an adapter, so a Windows host with three IPs on one NIC offers
  33. three bind targets rather than one (#3121) — the same thing iproute2 gives
  34. Linux. ``is_alias`` marks every address after an interface's first, which
  35. is the closest Windows equivalent of an iproute2 alias label.
  36. Filters: IPv4 only (matches the Linux path), skip loopback and
  37. link-local (169.254.0.0/16), skip interfaces psutil reports as down.
  38. Args:
  39. exclude_by_name: apply ``EXCLUDED_INTERFACE_PREFIXES``. Only ever true
  40. on Linux — those are Linux device names, and a Windows adapter
  41. named "Local Area Connection" would match the ``lo`` prefix. The
  42. address-class filters above cover the equivalent ground elsewhere,
  43. and users may legitimately want to bind a VP to a Hyper-V / WSL /
  44. Tailscale / utun adapter.
  45. """
  46. try:
  47. import psutil
  48. except ImportError:
  49. logger.warning("psutil not available, interface detection unavailable on this platform")
  50. return []
  51. entries = []
  52. try:
  53. addrs_by_iface = psutil.net_if_addrs()
  54. stats_by_iface = psutil.net_if_stats()
  55. except Exception as e:
  56. logger.error("psutil failed to enumerate interfaces: %s", e)
  57. return []
  58. for name, addrs in addrs_by_iface.items():
  59. if exclude_by_name and _is_excluded(name):
  60. continue
  61. stats = stats_by_iface.get(name)
  62. if stats is not None and not stats.isup:
  63. continue
  64. ipv4_count = 0
  65. for addr in addrs:
  66. if addr.family != socket.AF_INET:
  67. continue
  68. ip = addr.address
  69. netmask = addr.netmask
  70. if not ip or not netmask:
  71. continue
  72. try:
  73. ip_obj = ipaddress.IPv4Address(ip)
  74. except ValueError:
  75. continue
  76. if ip_obj.is_loopback or ip_obj.is_link_local:
  77. continue
  78. try:
  79. network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
  80. except ValueError:
  81. continue
  82. entries.append(
  83. {
  84. "name": name,
  85. "ip": ip,
  86. "netmask": netmask,
  87. "subnet": str(network),
  88. # No label to read on this path, so position is all we
  89. # have: the first address an adapter reports is its
  90. # primary, the rest are secondaries.
  91. "is_alias": ipv4_count > 0,
  92. "label": name,
  93. }
  94. )
  95. ipv4_count += 1
  96. return entries
  97. def _get_network_interfaces_psutil() -> list[dict]:
  98. """The primary IPv4 of each interface, in ``get_network_interfaces`` shape.
  99. That function's callers want one subnet per interface — discovery scan
  100. targets, the support bundle — not one entry per alias, so the secondary
  101. addresses are dropped here rather than never collected.
  102. """
  103. return [
  104. {key: entry[key] for key in ("name", "ip", "netmask", "subnet")}
  105. for entry in _psutil_ipv4_entries()
  106. if not entry["is_alias"]
  107. ]
  108. def _sort_interface_entries(entries: list[dict]) -> list[dict]:
  109. """Sort in place and return: primary IPs first per interface, then by name."""
  110. entries.sort(key=lambda e: (e["name"], e["is_alias"], e["ip"]))
  111. return entries
  112. def get_network_interfaces(include_excluded: bool = False) -> list[dict]:
  113. """Get all network interfaces with their IPs and subnets.
  114. Args:
  115. include_excluded: keep the interfaces ``EXCLUDED_INTERFACE_PREFIXES``
  116. normally hides. That list exists to keep docker0 and friends out
  117. of the Virtual Printer's bind dropdown; a caller asking about an
  118. address the kernel has already chosen needs the real answer.
  119. Returns:
  120. List of dicts with name, ip, netmask, subnet, broadcast
  121. """
  122. # Only Linux has the SIOCGIFADDR/SIOCGIFNETMASK ioctls + sockaddr layout the
  123. # path below relies on. Windows lacks fcntl entirely; macOS/BSD have fcntl but
  124. # different ioctl numbers, so the ioctl path there fails per-interface and
  125. # returns an empty list (breaking the VP bind-interface dropdown on macOS).
  126. # Route everything non-Linux to the cross-platform psutil path.
  127. if not sys.platform.startswith("linux"):
  128. return _get_network_interfaces_psutil()
  129. interfaces = []
  130. try:
  131. import fcntl
  132. for iface in socket.if_nameindex():
  133. name = iface[1]
  134. # Skip excluded interfaces
  135. if not include_excluded and _is_excluded(name):
  136. continue
  137. try:
  138. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  139. # Get IP address
  140. ip_bytes = fcntl.ioctl(
  141. s.fileno(),
  142. 0x8915, # SIOCGIFADDR
  143. struct.pack("256s", name[:15].encode()),
  144. )[20:24]
  145. ip = socket.inet_ntoa(ip_bytes)
  146. # Get netmask
  147. netmask_bytes = fcntl.ioctl(
  148. s.fileno(),
  149. 0x891B, # SIOCGIFNETMASK
  150. struct.pack("256s", name[:15].encode()),
  151. )[20:24]
  152. netmask = socket.inet_ntoa(netmask_bytes)
  153. # Calculate subnet
  154. network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
  155. interfaces.append(
  156. {
  157. "name": name,
  158. "ip": ip,
  159. "netmask": netmask,
  160. "subnet": str(network),
  161. }
  162. )
  163. s.close()
  164. except OSError:
  165. # Interface doesn't have an IP or other error
  166. pass
  167. except Exception as e:
  168. logger.debug("Error getting info for interface %s: %s", name, e)
  169. except ImportError:
  170. # fcntl not available (Windows)
  171. logger.warning("fcntl not available, interface detection limited")
  172. except Exception as e:
  173. logger.error("Error enumerating interfaces: %s", e)
  174. return interfaces
  175. def get_all_interface_ips(include_excluded: bool = False) -> list[dict]:
  176. """Get all IPs (primary + aliases) for every interface, minus the excluded ones.
  177. Uses `ip -j addr show` to see secondary/alias IPs that ioctl misses.
  178. Falls back to :func:`_fallback_get_all_ips` wherever `ip` isn't there to
  179. ask — which is every non-Linux host.
  180. Args:
  181. include_excluded: see :func:`get_network_interfaces`.
  182. Returns:
  183. List of dicts with name, ip, netmask, subnet, is_alias, label
  184. """
  185. # Windows and macOS have no `ip`, so there is nothing to try first. Going
  186. # straight to psutil is what lets a Windows NIC carrying three IPs offer
  187. # three bind targets instead of one (#3121).
  188. if not sys.platform.startswith("linux") or not _IP_CMD:
  189. logger.debug("ip command unavailable on this platform, enumerating via psutil")
  190. return _fallback_get_all_ips(include_excluded)
  191. try:
  192. result = subprocess.run(
  193. [_IP_CMD, "-j", "addr", "show"],
  194. capture_output=True,
  195. text=True,
  196. timeout=5,
  197. )
  198. if result.returncode != 0:
  199. logger.warning("ip addr show failed: %s", result.stderr)
  200. return _fallback_get_all_ips(include_excluded)
  201. interfaces_data = json.loads(result.stdout)
  202. except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:
  203. logger.warning("Failed to run ip -j addr show: %s", e)
  204. return _fallback_get_all_ips(include_excluded)
  205. entries = []
  206. for iface in interfaces_data:
  207. ifname = iface.get("ifname", "")
  208. if not include_excluded and _is_excluded(ifname):
  209. continue
  210. ipv4_count = 0
  211. for addr_info in iface.get("addr_info", []):
  212. if addr_info.get("family") != "inet":
  213. continue
  214. ip = addr_info.get("local", "")
  215. prefix = addr_info.get("prefixlen", 24)
  216. label = addr_info.get("label", ifname)
  217. try:
  218. network = ipaddress.IPv4Network(f"{ip}/{prefix}", strict=False)
  219. netmask = str(network.netmask)
  220. except ValueError:
  221. continue
  222. # An alias has ":" in label (e.g. eth0:vp1) or is not the first IPv4
  223. is_alias = ":" in label or ipv4_count > 0
  224. entries.append(
  225. {
  226. "name": ifname,
  227. "ip": ip,
  228. "netmask": netmask,
  229. "subnet": str(network),
  230. "is_alias": is_alias,
  231. "label": label,
  232. }
  233. )
  234. ipv4_count += 1
  235. return _sort_interface_entries(entries)
  236. def _fallback_get_all_ips(include_excluded: bool = False) -> list[dict]:
  237. """Enumerate without iproute2: psutil first, ioctl only if it finds nothing.
  238. psutil is the better answer because it reports secondary addresses, so a
  239. host with no `ip` command still gets one bind target per IP instead of per
  240. interface. The ioctl wrap below is what such a host used to get (minus the
  241. aliases it never saw) and is kept for the one case psutil can't serve: a
  242. hand-rolled venv missing the dependency. It only ever runs on Linux, since
  243. the ioctl path returns nothing anywhere else.
  244. """
  245. # EXCLUDED_INTERFACE_PREFIXES are Linux device names; see _psutil_ipv4_entries.
  246. exclude_by_name = sys.platform.startswith("linux") and not include_excluded
  247. entries = _psutil_ipv4_entries(exclude_by_name=exclude_by_name)
  248. if entries:
  249. # Deliberately not sorted. psutil's adapter order is what this path has
  250. # always returned, and find_interface_for_ip() answers with the first
  251. # entry whose subnet holds the target -- which the MQTT bridge uses as
  252. # the source IP for the #1429 rewrite and the SSDP proxy as its local
  253. # interface. Re-ordering it would quietly re-pick those on a host with
  254. # two adapters on one subnet. The iproute2 path sorts because it always
  255. # has; only Linux sees that order.
  256. return entries
  257. return [
  258. {
  259. **iface,
  260. "is_alias": False,
  261. "label": iface["name"],
  262. }
  263. for iface in get_network_interfaces(include_excluded)
  264. ]
  265. def find_local_ipv4_network(local_ip: str) -> ipaddress.IPv4Network | None:
  266. """The IPv4 network configured on the local interface holding ``local_ip``.
  267. An IPv4 address carries no prefix length, so the only way to know how far
  268. a LAN reaches is to read the prefix off the interface that owns the
  269. address. ``None`` means no local interface claims it, which is the honest
  270. answer whenever the platform gives us no interface data at all.
  271. Nothing is filtered: ``local_ip`` is an address the kernel already picked
  272. as a route source, so answering "unknown" because it happens to sit on a
  273. bridge named ``br-something`` would be a worse answer than the truth.
  274. """
  275. try:
  276. address = ipaddress.IPv4Address(local_ip)
  277. except ValueError:
  278. return None
  279. for iface in get_all_interface_ips(include_excluded=True):
  280. if iface.get("ip") != str(address):
  281. continue
  282. try:
  283. return ipaddress.IPv4Network(iface["subnet"], strict=False)
  284. except (KeyError, TypeError, ValueError):
  285. logger.debug("Interface %s has an unusable subnet %r", iface.get("name"), iface.get("subnet"))
  286. return None
  287. return None
  288. def find_interface_for_ip(target_ip: str) -> dict | None:
  289. """Find which interface is on the same subnet as the target IP.
  290. Args:
  291. target_ip: IP address to find the matching interface for
  292. Returns:
  293. Interface dict or None if not found
  294. """
  295. try:
  296. target = ipaddress.IPv4Address(target_ip)
  297. except ValueError:
  298. logger.error("Invalid target IP: %s", target_ip)
  299. return None
  300. interfaces = get_all_interface_ips()
  301. for iface in interfaces:
  302. if iface.get("is_alias"):
  303. continue
  304. try:
  305. network = ipaddress.IPv4Network(iface["subnet"], strict=False)
  306. if target in network:
  307. logger.debug("Found interface %s (%s) for target %s", iface["name"], iface["ip"], target_ip)
  308. return iface
  309. except ValueError:
  310. continue
  311. logger.warning("No interface found for target IP %s", target_ip)
  312. return None
  313. def get_other_interfaces(exclude_ip: str) -> list[dict]:
  314. """Get all interfaces except the one with the given IP.
  315. Args:
  316. exclude_ip: IP address of interface to exclude
  317. Returns:
  318. List of interface dicts
  319. """
  320. interfaces = get_network_interfaces()
  321. return [iface for iface in interfaces if iface["ip"] != exclude_ip]