test_network_utils.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. """Tests for network interface enumeration.
  2. Focus: the platform routing in get_network_interfaces(). macOS/BSD have fcntl
  3. but not the Linux SIOCGIFADDR/SIOCGIFNETMASK ioctls, so the ioctl path there
  4. silently returns nothing and the VP bind-interface dropdown comes up empty.
  5. Everything that isn't Linux must go through the cross-platform psutil path.
  6. """
  7. import socket
  8. from collections import namedtuple
  9. from unittest.mock import patch
  10. from backend.app.services import network_utils
  11. # Mimic the shape of psutil.net_if_addrs() / net_if_stats() entries we read.
  12. _Addr = namedtuple("snicaddr", ["family", "address", "netmask", "broadcast", "ptp"])
  13. _Stats = namedtuple("snicstats", ["isup", "duplex", "speed", "mtu", "flags"])
  14. def _fake_psutil():
  15. addrs = {
  16. "en0": [_Addr(socket.AF_INET, "192.168.1.50", "255.255.255.0", None, None)],
  17. "lo0": [_Addr(socket.AF_INET, "127.0.0.1", "255.0.0.0", None, None)],
  18. "awdl0": [_Addr(socket.AF_INET, "169.254.10.20", "255.255.0.0", None, None)],
  19. "utun3": [_Addr(socket.AF_INET, "100.64.0.7", "255.255.255.255", None, None)],
  20. "en5": [_Addr(socket.AF_INET, "10.0.0.9", "255.255.255.0", None, None)],
  21. }
  22. stats = {
  23. "en0": _Stats(True, 0, 0, 1500, 0),
  24. "lo0": _Stats(True, 0, 0, 16384, 0),
  25. "awdl0": _Stats(True, 0, 0, 1500, 0),
  26. "utun3": _Stats(True, 0, 0, 1500, 0),
  27. "en5": _Stats(False, 0, 0, 1500, 0), # down → skipped
  28. }
  29. return addrs, stats
  30. @patch("backend.app.services.network_utils.sys")
  31. def test_macos_routes_to_psutil(mock_sys):
  32. """On darwin, get_network_interfaces() must use psutil, not the ioctl path."""
  33. mock_sys.platform = "darwin"
  34. with patch.object(network_utils, "_get_network_interfaces_psutil", return_value=[{"name": "en0"}]) as psutil_path:
  35. result = network_utils.get_network_interfaces()
  36. psutil_path.assert_called_once()
  37. assert result == [{"name": "en0"}]
  38. @patch("backend.app.services.network_utils.sys")
  39. def test_windows_routes_to_psutil(mock_sys):
  40. mock_sys.platform = "win32"
  41. with patch.object(network_utils, "_get_network_interfaces_psutil", return_value=[]) as psutil_path:
  42. network_utils.get_network_interfaces()
  43. psutil_path.assert_called_once()
  44. @patch("backend.app.services.network_utils.sys")
  45. def test_linux_does_not_use_psutil(mock_sys):
  46. """Linux keeps the ioctl path — psutil helper must not be invoked."""
  47. mock_sys.platform = "linux"
  48. with patch.object(network_utils, "_get_network_interfaces_psutil") as psutil_path:
  49. # The ioctl path runs for real here; we only assert it wasn't short-circuited
  50. # to psutil. Its actual return depends on the host, so we don't assert on it.
  51. network_utils.get_network_interfaces()
  52. psutil_path.assert_not_called()
  53. def test_psutil_path_filters_and_returns_bindable_ips():
  54. """The psutil path drops loopback/link-local/down ifaces, keeps real + VPN ones."""
  55. addrs, stats = _fake_psutil()
  56. with (
  57. patch("psutil.net_if_addrs", return_value=addrs),
  58. patch("psutil.net_if_stats", return_value=stats),
  59. ):
  60. result = network_utils._get_network_interfaces_psutil()
  61. by_name = {i["name"]: i for i in result}
  62. assert "en0" in by_name # normal LAN interface
  63. assert by_name["en0"]["ip"] == "192.168.1.50"
  64. assert by_name["en0"]["subnet"] == "192.168.1.0/24"
  65. assert "utun3" in by_name # Tailscale/VPN — legitimately bindable
  66. assert "lo0" not in by_name # loopback filtered
  67. assert "awdl0" not in by_name # link-local (169.254) filtered
  68. assert "en5" not in by_name # interface down, skipped
  69. _IP_ADDR_JSON = """[
  70. {"ifname": "lo", "addr_info": [{"family": "inet", "local": "127.0.0.1", "prefixlen": 8}]},
  71. {"ifname": "enp3s0", "addr_info": [{"family": "inet", "local": "192.168.96.9", "prefixlen": 22}]},
  72. {"ifname": "enp4s0", "addr_info": [
  73. {"family": "inet", "local": "10.0.0.5", "prefixlen": 24},
  74. {"family": "inet", "local": "10.0.0.6", "prefixlen": 24, "label": "enp4s0:vp1"}
  75. ]},
  76. {"ifname": "docker0", "addr_info": [{"family": "inet", "local": "172.17.0.1", "prefixlen": 16}]}
  77. ]"""
  78. def _fake_ip_addr():
  79. """Patch `ip -j addr show` with a fixed multi-homed Linux host."""
  80. result = namedtuple("CompletedProcess", ["returncode", "stdout", "stderr"])(0, _IP_ADDR_JSON, "")
  81. return patch.object(network_utils, "subprocess", **{"run.return_value": result})
  82. class TestFindLocalIPv4Network:
  83. """#3092: an address carries no prefix, so it has to be read off the interface."""
  84. def test_reads_the_configured_prefix_not_a_guessed_24(self):
  85. with _fake_ip_addr(), patch.object(network_utils, "_IP_CMD", "/usr/sbin/ip"):
  86. assert str(network_utils.find_local_ipv4_network("192.168.96.9")) == "192.168.96.0/22"
  87. def test_an_alias_address_resolves_too(self):
  88. # The VP binds aliases; an alias is a perfectly good route source.
  89. with _fake_ip_addr(), patch.object(network_utils, "_IP_CMD", "/usr/sbin/ip"):
  90. assert str(network_utils.find_local_ipv4_network("10.0.0.6")) == "10.0.0.0/24"
  91. def test_an_excluded_interface_still_answers(self):
  92. """EXCLUDED_INTERFACE_PREFIXES keeps docker0 out of the VP dropdown.
  93. It must not also make the kernel's own choice of route source
  94. unanswerable — "unknown" would be a worse answer than the truth.
  95. """
  96. with _fake_ip_addr(), patch.object(network_utils, "_IP_CMD", "/usr/sbin/ip"):
  97. assert str(network_utils.find_local_ipv4_network("172.17.0.1")) == "172.17.0.0/16"
  98. assert not [i for i in network_utils.get_all_interface_ips() if i["name"] == "docker0"]
  99. def test_an_address_no_interface_holds_is_none(self):
  100. with _fake_ip_addr(), patch.object(network_utils, "_IP_CMD", "/usr/sbin/ip"):
  101. assert network_utils.find_local_ipv4_network("192.168.1.1") is None
  102. def test_a_hostname_is_none(self):
  103. assert network_utils.find_local_ipv4_network("printer.local") is None
  104. def _fake_windows_psutil():
  105. """#3121's host: one vmxnet3 NIC carrying three IPv4 addresses.
  106. "Local Area Connection" is here on purpose — it starts with ``lo``, so it
  107. is what EXCLUDED_INTERFACE_PREFIXES would eat if the Linux name filter were
  108. applied to Windows adapter names.
  109. """
  110. addrs = {
  111. "Ethernet0": [
  112. _Addr(socket.AF_INET, "10.10.24.6", "255.255.255.0", None, None),
  113. _Addr(socket.AF_INET, "10.10.24.7", "255.255.255.0", None, None),
  114. _Addr(socket.AF_INET, "10.10.24.8", "255.255.255.0", None, None),
  115. ],
  116. "Local Area Connection": [_Addr(socket.AF_INET, "192.168.7.5", "255.255.255.0", None, None)],
  117. }
  118. stats = {
  119. "Ethernet0": _Stats(True, 0, 0, 1500, 0),
  120. "Local Area Connection": _Stats(True, 0, 0, 1500, 0),
  121. }
  122. return addrs, stats
  123. def _patch_psutil(addrs, stats):
  124. """Both psutil calls the enumerator makes, as one context manager."""
  125. return patch.multiple(
  126. "psutil",
  127. net_if_addrs=lambda: addrs,
  128. net_if_stats=lambda: stats,
  129. )
  130. class TestSecondaryAddresses:
  131. """#3121: a NIC with several IPv4 addresses is several VP bind targets.
  132. The Virtual Printer needs one bind IP per printer. Linux gets one dropdown
  133. entry per alias from `ip -j addr show`; Windows and macOS have no `ip`, so
  134. everything they offer comes out of psutil.
  135. """
  136. def test_every_ipv4_on_an_interface_is_listed(self):
  137. addrs, stats = _fake_windows_psutil()
  138. with _patch_psutil(addrs, stats):
  139. entries = network_utils._psutil_ipv4_entries()
  140. eth0 = [e for e in entries if e["name"] == "Ethernet0"]
  141. assert [e["ip"] for e in eth0] == ["10.10.24.6", "10.10.24.7", "10.10.24.8"]
  142. # Position is the only alias signal on this path: first = primary.
  143. assert [e["is_alias"] for e in eth0] == [False, True, True]
  144. assert {e["subnet"] for e in eth0} == {"10.10.24.0/24"}
  145. def test_get_network_interfaces_still_returns_one_per_interface(self):
  146. """Discovery subnets and the support bundle want interfaces, not aliases."""
  147. addrs, stats = _fake_windows_psutil()
  148. with _patch_psutil(addrs, stats):
  149. result = network_utils._get_network_interfaces_psutil()
  150. assert [i["ip"] for i in result if i["name"] == "Ethernet0"] == ["10.10.24.6"]
  151. # The narrower shape this function has always returned.
  152. assert set(result[0]) == {"name", "ip", "netmask", "subnet"}
  153. @patch("backend.app.services.network_utils.sys")
  154. def test_windows_dropdown_offers_each_secondary_ip(self, mock_sys):
  155. """The actual bug: only one entry per NIC reached the bind dropdown."""
  156. mock_sys.platform = "win32"
  157. addrs, stats = _fake_windows_psutil()
  158. with _patch_psutil(addrs, stats):
  159. entries = network_utils.get_all_interface_ips()
  160. assert [e["ip"] for e in entries if e["name"] == "Ethernet0"] == [
  161. "10.10.24.6",
  162. "10.10.24.7",
  163. "10.10.24.8",
  164. ]
  165. @patch("backend.app.services.network_utils.sys")
  166. def test_windows_keeps_adapters_matching_a_linux_prefix(self, mock_sys):
  167. """EXCLUDED_INTERFACE_PREFIXES must not run against Windows names."""
  168. mock_sys.platform = "win32"
  169. addrs, stats = _fake_windows_psutil()
  170. with _patch_psutil(addrs, stats):
  171. entries = network_utils.get_all_interface_ips()
  172. assert "Local Area Connection" in {e["name"] for e in entries}
  173. @patch("backend.app.services.network_utils.sys")
  174. def test_linux_without_iproute2_gets_aliases_and_keeps_its_exclusions(self, mock_sys):
  175. """psutil replaces the ioctl fallback, so no-`ip` hosts see aliases too.
  176. The name exclusions still apply here — unlike Windows, these really are
  177. the local device names, and docker0 has no business in the dropdown.
  178. """
  179. mock_sys.platform = "linux"
  180. addrs = {
  181. "eth0": [
  182. _Addr(socket.AF_INET, "192.168.1.100", "255.255.255.0", None, None),
  183. _Addr(socket.AF_INET, "192.168.1.101", "255.255.255.0", None, None),
  184. ],
  185. "docker0": [_Addr(socket.AF_INET, "172.17.0.1", "255.255.0.0", None, None)],
  186. }
  187. stats = {"eth0": _Stats(True, 0, 0, 1500, 0), "docker0": _Stats(True, 0, 0, 1500, 0)}
  188. with _patch_psutil(addrs, stats), patch.object(network_utils, "_IP_CMD", None):
  189. entries = network_utils.get_all_interface_ips()
  190. unfiltered = network_utils.get_all_interface_ips(include_excluded=True)
  191. assert [e["ip"] for e in entries] == ["192.168.1.100", "192.168.1.101"]
  192. assert "docker0" not in {e["name"] for e in entries}
  193. assert "docker0" in {e["name"] for e in unfiltered}
  194. @patch("backend.app.services.network_utils.sys")
  195. def test_ioctl_remains_the_last_resort(self, mock_sys):
  196. """A venv without psutil still enumerates, just without the aliases."""
  197. mock_sys.platform = "linux"
  198. with (
  199. patch.object(network_utils, "_IP_CMD", None),
  200. patch.object(network_utils, "_psutil_ipv4_entries", return_value=[]),
  201. patch.object(
  202. network_utils,
  203. "get_network_interfaces",
  204. return_value=[
  205. {"name": "eth0", "ip": "192.168.1.100", "netmask": "255.255.255.0", "subnet": "192.168.1.0/24"}
  206. ],
  207. ),
  208. ):
  209. entries = network_utils.get_all_interface_ips()
  210. assert entries == [
  211. {
  212. "name": "eth0",
  213. "ip": "192.168.1.100",
  214. "netmask": "255.255.255.0",
  215. "subnet": "192.168.1.0/24",
  216. "is_alias": False,
  217. "label": "eth0",
  218. }
  219. ]
  220. @patch("backend.app.services.network_utils.sys")
  221. def test_adapter_order_is_preserved_for_source_ip_selection(self, mock_sys):
  222. """find_interface_for_ip() answers with the first match, so order matters.
  223. The MQTT bridge takes that answer as the source IP for the #1429
  224. rewrite and the SSDP proxy as its local interface. On a host with two
  225. adapters on one subnet, re-ordering the enumeration would silently
  226. re-pick both, so this path stays in psutil's adapter order rather than
  227. being sorted by name the way the iproute2 path is.
  228. """
  229. mock_sys.platform = "win32"
  230. addrs = {
  231. "Zeta": [_Addr(socket.AF_INET, "10.10.24.6", "255.255.255.0", None, None)],
  232. "Alpha": [_Addr(socket.AF_INET, "10.10.24.9", "255.255.255.0", None, None)],
  233. }
  234. stats = {"Zeta": _Stats(True, 0, 0, 1500, 0), "Alpha": _Stats(True, 0, 0, 1500, 0)}
  235. with _patch_psutil(addrs, stats):
  236. assert [e["name"] for e in network_utils.get_all_interface_ips()] == ["Zeta", "Alpha"]
  237. assert network_utils.find_interface_for_ip("10.10.24.200")["name"] == "Zeta"