From 76889576b82fb381cc33a7b7b24754e5b3c6a5be Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 2 Jul 2026 10:34:48 -0400 Subject: [PATCH 1/6] Deprecate lossy {mac: ip} neighbour shape; add Windows neighbour support network.arp, network.ip_neighs and network.ip_neighs6 return a flat {mac: ip} dict, so a MAC that appears more than once in the neighbour table keeps only one arbitrary entry. Multiple IPs per MAC is normal: routers hold several addresses on one interface, and with IPv6 nearly every neighbour has at least a link-local and a global address, so ip_neighs6 drops entries for practically every host on the segment. The flat shape also discards the interface and reachability state. Add an expand argument to all three functions. expand=True returns a list of {ip, mac, dev, state} entry dicts; expand=False keeps the legacy shape. Calls without expand keep the legacy shape but emit a DeprecationWarning via warn_until(3011): per the deprecation policy the warning stays in place for 3009 and 3010, and the default flips to the new shape in 3011. Windows minions had no neighbour table support at all (win_network loads as the network module but never implemented arp). Implement all three functions there on top of Get-NetNeighbor, with the same expand argument and the same deprecation cycle, so network.arp behaves identically on every platform throughout the transition. MACs are normalized to the lowercase colon-separated Unix form. Fixes #69655 --- changelog/69655.added.md | 1 + changelog/69655.fixed.md | 1 + salt/modules/network.py | 191 +++- salt/modules/win_network.py | 167 +++ tests/pytests/unit/modules/test_network.py | 994 +++++++++++++++++- .../pytests/unit/modules/test_win_network.py | 155 +++ tests/unit/modules/test_network.py | 2 +- 7 files changed, 1500 insertions(+), 11 deletions(-) create mode 100644 changelog/69655.added.md create mode 100644 changelog/69655.fixed.md diff --git a/changelog/69655.added.md b/changelog/69655.added.md new file mode 100644 index 000000000000..d5162d032091 --- /dev/null +++ b/changelog/69655.added.md @@ -0,0 +1 @@ +Added ``network.arp``, ``network.ip_neighs`` and ``network.ip_neighs6`` on Windows minions, backed by ``Get-NetNeighbor``. The functions take the same ``expand`` argument and follow the same deprecation cycle as their Unix counterparts, so ``network.arp`` behaves identically on every platform throughout the transition. diff --git a/changelog/69655.fixed.md b/changelog/69655.fixed.md new file mode 100644 index 000000000000..b02757a54ccb --- /dev/null +++ b/changelog/69655.fixed.md @@ -0,0 +1 @@ +Deprecated the ``{mac: ip}`` return shape of ``network.arp``, ``network.ip_neighs`` and ``network.ip_neighs6``, which silently drops neighbour entries whenever several IP addresses share a MAC address (with IPv6 this loses entries for practically every host, since neighbours normally hold at least a link-local and a global address on the same MAC). A new ``expand`` argument opts in to a list of ``{ip, mac, dev, state}`` entry dicts, which will become the default return shape in salt 3011; until then, calls without ``expand`` emit a ``DeprecationWarning``. diff --git a/salt/modules/network.py b/salt/modules/network.py index 8fd4d9bbffb7..faef951daed3 100644 --- a/salt/modules/network.py +++ b/salt/modules/network.py @@ -17,6 +17,7 @@ import salt.utils.network import salt.utils.platform import salt.utils.validate.net +import salt.utils.versions from salt._compat import ipaddress from salt.exceptions import CommandExecutionError @@ -1087,21 +1088,65 @@ def dig(host): return __salt__["cmd.run"](cmd) +def _neigh_expand_warning(func_name, expand): + """ + Warn about the upcoming neighbour table return shape change when the + caller did not pass ``expand`` explicitly, and return the effective + value of ``expand``. + """ + if expand is None: + salt.utils.versions.warn_until( + 3011, + f"In salt 3011, {func_name} will return a list of neighbour entry " + "dicts by default instead of a mac-to-ip mapping, which silently " + "drops entries whenever several IP addresses share a MAC address. " + "Pass expand=True to opt in to the new shape now, or expand=False " + "to keep the current shape and silence this warning.", + ) + return False + return expand + + +def _neighs_flatten(entries): + """ + Flatten neighbour entry dicts into the legacy ``{mac: ip}`` shape. When + several entries share a MAC address, the last one wins, matching the + historical behaviour. + """ + return {entry["mac"]: entry["ip"] for entry in entries} + + @salt.utils.decorators.path.which("arp") -def arp(): +def arp(expand=None): """ Return the arp table from the minion + expand : None + If ``True``, return a list of neighbour entry dicts of the form + ``{"ip": ..., "mac": ..., "dev": ..., "state": ...}`` instead of the + legacy ``{mac: ip}`` mapping. The legacy mapping can only hold one + entry per MAC address, so any further IP addresses sharing that MAC + are silently dropped from it. Keys that ``arp -an`` does not report + on a given platform are set to ``None``. + .. versionchanged:: 2015.8.0 Added support for SunOS + .. versionchanged:: 3009.0 + Added the ``expand`` argument. The list-of-entries shape it enables + will become the default return shape in salt 3011; until then, + calling this function without ``expand`` emits a + ``DeprecationWarning``. + CLI Example: .. code-block:: bash salt '*' network.arp + salt '*' network.arp expand=True """ - ret = {} + expand = _neigh_expand_warning("network.arp", expand) + entries = [] out = __salt__["cmd.run"]("arp -an") for line in out.splitlines(): comps = line.split() @@ -1110,19 +1155,49 @@ def arp(): if __grains__["kernel"] == "SunOS": if ":" not in comps[-1]: continue - ret[comps[-1]] = comps[1] + entries.append( + { + "ip": comps[1], + "mac": comps[-1], + "dev": comps[0], + "state": None, + } + ) elif __grains__["kernel"] == "OpenBSD": if comps[0] == "Host" or comps[1] == "(incomplete)": continue - ret[comps[1]] = comps[0] + entries.append( + { + "ip": comps[0], + "mac": comps[1], + "dev": comps[2] if len(comps) > 2 else None, + "state": None, + } + ) elif __grains__["kernel"] == "AIX": if comps[0] in ("bucket", "There"): continue - ret[comps[3]] = comps[1].strip("(").strip(")") + entries.append( + { + "ip": comps[1].strip("(").strip(")"), + "mac": comps[3], + "dev": None, + "state": None, + } + ) else: - ret[comps[3]] = comps[1].strip("(").strip(")") + entries.append( + { + "ip": comps[1].strip("(").strip(")"), + "mac": comps[3], + "dev": comps[comps.index("on") + 1] if "on" in comps[:-1] else None, + "state": None, + } + ) - return ret + if expand: + return entries + return _neighs_flatten(entries) def interfaces(): @@ -1343,6 +1418,108 @@ def ip_addrs6(interface=None, include_loopback=False, cidr=None): ipaddrs6 = salt.utils.functools.alias_function(ip_addrs6, "ipaddrs6") +def _parse_ip_neigh(family_char): + """ + Parse ``ip neigh show`` output into a list of neighbour entry dicts for + the address family whose addresses contain ``family_char`` ("." for IPv4, + ":" for IPv6). Only resolved entries (those carrying a link-layer + address) are included, matching the historical behaviour. + """ + entries = [] + out = __salt__["cmd.run"]("ip neigh show") + for line in out.splitlines(): + comps = line.split() + if len(comps) < 5: + continue + if family_char not in comps[0]: + continue + entries.append( + { + "ip": comps[0], + "mac": comps[4], + "dev": comps[2], + "state": comps[-1] if comps[-1] != comps[4] else None, + } + ) + + return entries + + +def ip_neighs(expand=None): + """ + Return the ip neighbour (arp) table from the minion for IPv4 addresses + + expand : None + If ``True``, return a list of neighbour entry dicts of the form + ``{"ip": ..., "mac": ..., "dev": ..., "state": ...}`` instead of the + legacy ``{mac: ip}`` mapping. The legacy mapping can only hold one + entry per MAC address, so any further IP addresses sharing that MAC + are silently dropped from it. + + .. versionadded:: 3006.0 + + .. versionchanged:: 3009.0 + Added the ``expand`` argument. The list-of-entries shape it enables + will become the default return shape in salt 3011; until then, + calling this function without ``expand`` emits a + ``DeprecationWarning``. + + CLI Example: + + .. code-block:: bash + + salt '*' network.ip_neighs + salt '*' network.ip_neighs expand=True + """ + expand = _neigh_expand_warning("network.ip_neighs", expand) + entries = _parse_ip_neigh(".") + if expand: + return entries + return _neighs_flatten(entries) + + +ipneighs = salt.utils.functools.alias_function(ip_neighs, "ipneighs") + + +def ip_neighs6(expand=None): + """ + Return the ip neighbour (arp) table from the minion for IPv6 addresses + + expand : None + If ``True``, return a list of neighbour entry dicts of the form + ``{"ip": ..., "mac": ..., "dev": ..., "state": ...}`` instead of the + legacy ``{mac: ip}`` mapping. The legacy mapping can only hold one + entry per MAC address, so any further IP addresses sharing that MAC + are silently dropped from it. Because IPv6 hosts normally hold at + least a link-local and a global address on the same MAC, the legacy + mapping loses entries for practically every neighbour; ``expand=True`` + is strongly recommended. + + .. versionadded:: 3006.0 + + .. versionchanged:: 3009.0 + Added the ``expand`` argument. The list-of-entries shape it enables + will become the default return shape in salt 3011; until then, + calling this function without ``expand`` emits a + ``DeprecationWarning``. + + CLI Example: + + .. code-block:: bash + + salt '*' network.ip_neighs6 + salt '*' network.ip_neighs6 expand=True + """ + expand = _neigh_expand_warning("network.ip_neighs6", expand) + entries = _parse_ip_neigh(":") + if expand: + return entries + return _neighs_flatten(entries) + + +ipneighs6 = salt.utils.functools.alias_function(ip_neighs6, "ipneighs6") + + def get_hostname(): """ Get hostname diff --git a/salt/modules/win_network.py b/salt/modules/win_network.py index daaba746b1df..958541bdeca3 100644 --- a/salt/modules/win_network.py +++ b/salt/modules/win_network.py @@ -7,9 +7,11 @@ import re import socket +import salt.utils.functools import salt.utils.network import salt.utils.platform import salt.utils.validate.net +import salt.utils.versions from salt._compat import ipaddress from salt.modules.network import ( calc_net, @@ -627,3 +629,168 @@ def is_private(ip_addr): salt '*' network.is_private 10.0.0.3 """ return ipaddress.ip_address(ip_addr).is_private + + +def _neigh_expand_warning(func_name, expand): + """ + Warn about the upcoming neighbour table return shape change when the + caller did not pass ``expand`` explicitly, and return the effective + value of ``expand``. + """ + if expand is None: + salt.utils.versions.warn_until( + 3011, + f"In salt 3011, {func_name} will return a list of neighbour entry " + "dicts by default instead of a mac-to-ip mapping, which silently " + "drops entries whenever several IP addresses share a MAC address. " + "Pass expand=True to opt in to the new shape now, or expand=False " + "to keep the current shape and silence this warning.", + ) + return False + return expand + + +def _get_neighbors(address_family): + """ + Return the neighbour (ARP/NDP) table for the given address family + ("IPv4" or "IPv6") as a list of entry dicts, via Get-NetNeighbor. + Unresolved entries (no link-layer address) are skipped, and MAC + addresses are normalized to the lowercase colon-separated form used by + the Unix network module. + """ + cmd = ( + f"Get-NetNeighbor -AddressFamily {address_family} | " + "Select-Object IPAddress, LinkLayerAddress, InterfaceAlias, " + "@{Name='State'; Expression={$_.State.ToString()}}" + ) + results = __salt__["cmd.powershell"](cmd) + if isinstance(results, dict): + # A single neighbour serializes to a bare object rather than a list + results = [results] + + entries = [] + for neighbor in results: + mac = neighbor.get("LinkLayerAddress") + if not mac: + # Unreachable/incomplete entries carry no link-layer address + continue + entries.append( + { + "ip": neighbor.get("IPAddress"), + "mac": mac.replace("-", ":").lower(), + "dev": neighbor.get("InterfaceAlias"), + "state": neighbor.get("State"), + } + ) + + return entries + + +def _neighs_flatten(entries): + """ + Flatten neighbour entry dicts into the legacy ``{mac: ip}`` shape used + by the Unix network module. When several entries share a MAC address, + the last one wins. + """ + return {entry["mac"]: entry["ip"] for entry in entries} + + +def arp(expand=None): + """ + Return the arp table from the minion + + expand : None + If ``True``, return a list of neighbour entry dicts of the form + ``{"ip": ..., "mac": ..., "dev": ..., "state": ...}`` instead of the + legacy ``{mac: ip}`` mapping used by the Unix network module. The + legacy mapping can only hold one entry per MAC address, so any + further IP addresses sharing that MAC are silently dropped from it. + + .. versionadded:: 3009.0 + The list-of-entries shape enabled by ``expand=True`` will become the + default return shape in salt 3011; until then, calling this function + without ``expand`` emits a ``DeprecationWarning``, matching the + deprecation cycle of the Unix network module. + + CLI Example: + + .. code-block:: bash + + salt '*' network.arp + salt '*' network.arp expand=True + """ + expand = _neigh_expand_warning("network.arp", expand) + entries = _get_neighbors("IPv4") + if expand: + return entries + return _neighs_flatten(entries) + + +def ip_neighs(expand=None): + """ + Return the ip neighbour (arp) table from the minion for IPv4 addresses + + expand : None + If ``True``, return a list of neighbour entry dicts of the form + ``{"ip": ..., "mac": ..., "dev": ..., "state": ...}`` instead of the + legacy ``{mac: ip}`` mapping used by the Unix network module. The + legacy mapping can only hold one entry per MAC address, so any + further IP addresses sharing that MAC are silently dropped from it. + + .. versionadded:: 3009.0 + The list-of-entries shape enabled by ``expand=True`` will become the + default return shape in salt 3011; until then, calling this function + without ``expand`` emits a ``DeprecationWarning``, matching the + deprecation cycle of the Unix network module. + + CLI Example: + + .. code-block:: bash + + salt '*' network.ip_neighs + salt '*' network.ip_neighs expand=True + """ + expand = _neigh_expand_warning("network.ip_neighs", expand) + entries = _get_neighbors("IPv4") + if expand: + return entries + return _neighs_flatten(entries) + + +ipneighs = salt.utils.functools.alias_function(ip_neighs, "ipneighs") + + +def ip_neighs6(expand=None): + """ + Return the ip neighbour (NDP) table from the minion for IPv6 addresses + + expand : None + If ``True``, return a list of neighbour entry dicts of the form + ``{"ip": ..., "mac": ..., "dev": ..., "state": ...}`` instead of the + legacy ``{mac: ip}`` mapping used by the Unix network module. The + legacy mapping can only hold one entry per MAC address, and IPv6 + hosts normally hold at least a link-local and a global address on + the same MAC, so the legacy mapping loses entries for practically + every neighbour; ``expand=True`` is strongly recommended. + + .. versionadded:: 3009.0 + The list-of-entries shape enabled by ``expand=True`` will become the + default return shape in salt 3011; until then, calling this function + without ``expand`` emits a ``DeprecationWarning``, matching the + deprecation cycle of the Unix network module. + + CLI Example: + + .. code-block:: bash + + salt '*' network.ip_neighs6 + salt '*' network.ip_neighs6 expand=True + """ + expand = _neigh_expand_warning("network.ip_neighs6", expand) + entries = _get_neighbors("IPv6") + if expand: + return entries + return _neighs_flatten(entries) + + +ipneighs6 = salt.utils.functools.alias_function(ip_neighs6, "ipneighs6") diff --git a/tests/pytests/unit/modules/test_network.py b/tests/pytests/unit/modules/test_network.py index 81035434b610..499872ba6b31 100644 --- a/tests/pytests/unit/modules/test_network.py +++ b/tests/pytests/unit/modules/test_network.py @@ -1,14 +1,25 @@ +import os +import os.path +import shutil +import socket import threading +import warnings import pytest +import salt.loader import salt.modules.network as networkmod -from tests.support.mock import patch +from salt._compat import ipaddress +from salt.exceptions import CommandExecutionError +from tests.support.mock import MagicMock, mock_open, patch @pytest.fixture -def configure_loader_modules(): - return {networkmod: {}} +def configure_loader_modules(minion_opts): + utils = salt.loader.utils( + minion_opts, whitelist=["network", "path", "platform", "stringutils"] + ) + return {networkmod: {"__utils__": utils}} @pytest.fixture @@ -95,3 +106,980 @@ def test_fqdns_should_return_sorted_unique_domains(fake_ips): assert actual_fqdns == { "fqdns": ["a.example.com", "c.example.com", "z.example.com"] } + + +def test___virtual__is_windows_true(): + with patch("salt.utils.platform.is_windows", return_value=True): + result = networkmod.__virtual__() + expected = ( + False, + "The network execution module cannot be loaded on Windows: use win_network" + " instead.", + ) + assert result == expected + + +def test___virtual__is_windows_false(): + with patch("salt.utils.platform.is_windows", return_value=False): + result = networkmod.__virtual__() + assert result + + +def test_wol_bad_mac(): + """ + tests network.wol with bad mac + """ + bad_mac = "31337" + pytest.raises(ValueError, networkmod.wol, bad_mac) + + +def test_wol_success(): + """ + tests network.wol success + """ + mac = "080027136977" + bcast = "255.255.255.255 7" + + class MockSocket: + def __init__(self, *args, **kwargs): + pass + + def __call__(self, *args, **kwargs): + pass + + def setsockopt(self, *args, **kwargs): + pass + + def sendto(self, *args, **kwargs): + pass + + with patch("socket.socket", MockSocket): + assert networkmod.wol(mac, bcast) + + +def test_ping(): + """ + Test for Performs a ping to a host + """ + with patch.dict( + networkmod.__utils__, {"network.sanitize_host": MagicMock(return_value="A")} + ): + mock_all = MagicMock(side_effect=[{"retcode": 1}, {"retcode": 0}]) + with patch.dict(networkmod.__salt__, {"cmd.run_all": mock_all}): + assert not networkmod.ping("host", return_boolean=True) + assert networkmod.ping("host", return_boolean=True) + + with patch.dict(networkmod.__salt__, {"cmd.run": MagicMock(return_value="A")}): + assert networkmod.ping("host") == "A" + + +def test_netstat(): + """ + Test for return information on open ports and states + """ + with patch.dict(networkmod.__grains__, {"kernel": "Linux"}): + with patch.object(networkmod, "_netstat_linux", return_value="A"): + with patch.object(networkmod, "_ss_linux", return_value="A"): + assert networkmod.netstat() == "A" + + with patch.dict(networkmod.__grains__, {"kernel": "OpenBSD"}): + with patch.object(networkmod, "_netstat_bsd", return_value="A"): + assert networkmod.netstat() == "A" + + with patch.dict(networkmod.__grains__, {"kernel": "A"}): + pytest.raises(CommandExecutionError, networkmod.netstat) + + +def test_active_tcp(): + """ + Test for return a dict containing information on all + of the running TCP connections + """ + with patch.dict( + networkmod.__utils__, {"network.active_tcp": MagicMock(return_value="A")} + ): + with patch.dict(networkmod.__grains__, {"kernel": "Linux"}): + assert networkmod.active_tcp() == "A" + + +def test_traceroute(): + """ + Test for Performs a traceroute to a 3rd party host + """ + + def patched_which(binary): + binary_path = shutil.which(binary) + if binary_path: + # The path exists, just return it + return binary_path + if binary == "traceroute": + # The path doesn't exist but we mock it on the test. + # Return the binary name + return binary + # The binary does not exist + return binary_path + + with patch("salt.utils.path.which", patched_which): + with patch.dict(networkmod.__salt__, {"cmd.run": MagicMock(return_value="")}): + assert networkmod.traceroute("gentoo.org") == [] + + with patch.dict( + networkmod.__utils__, + {"network.sanitize_host": MagicMock(return_value="gentoo.org")}, + ): + with patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value="")} + ): + assert networkmod.traceroute("gentoo.org") == [] + + +def test_dig(): + """ + Test for Performs a DNS lookup with dig + """ + with patch("salt.utils.path.which", MagicMock(return_value="dig")), patch.dict( + networkmod.__utils__, {"network.sanitize_host": MagicMock(return_value="A")} + ), patch.dict(networkmod.__salt__, {"cmd.run": MagicMock(return_value="A")}): + assert networkmod.dig("host") == "A" + + +def test_arp(): + """ + Test for return the arp table from the minion + """ + with patch.dict( + networkmod.__salt__, + {"cmd.run": MagicMock(return_value="A,B,C,D\nE,F,G,H\n")}, + ), patch("salt.utils.path.which", MagicMock(return_value="")): + assert networkmod.arp(expand=False) == {} + + +def test_arp_expand_linux(): + """ + arp(expand=True) returns one entry dict per arp -an line, preserving + multiple IP addresses that share a MAC address, with the interface + parsed from the "on" token. arp -an does not report a neighbour state. + """ + arp_out = ( + "? (203.0.113.1) at 00:00:5e:00:53:01 [ether] on eth0\n" + "? (203.0.113.9) at 00:00:5e:00:53:01 [ether] on eth1\n" + ) + with patch.dict(networkmod.__grains__, {"kernel": "Linux"}), patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value=arp_out)} + ), patch("salt.utils.path.which", MagicMock(return_value="/usr/sbin/arp")): + assert networkmod.arp(expand=True) == [ + { + "ip": "203.0.113.1", + "mac": "00:00:5e:00:53:01", + "dev": "eth0", + "state": None, + }, + { + "ip": "203.0.113.9", + "mac": "00:00:5e:00:53:01", + "dev": "eth1", + "state": None, + }, + ] + + +def test_arp_expand_sunos(): + """ + arp(expand=True) parses the SunOS netstat-style table, taking the + device from the first column and skipping the header lines. + """ + arp_out = ( + "Net to Media Table: IPv4\n" + "Device IP Address Mask Flags Phys Addr\n" + "------ ----------------- --------- ---------- -----------------\n" + "e1000g0 203.0.113.1 255.255.255.255 o 00:00:5e:00:53:01\n" + ) + with patch.dict(networkmod.__grains__, {"kernel": "SunOS"}), patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value=arp_out)} + ), patch("salt.utils.path.which", MagicMock(return_value="/usr/sbin/arp")): + assert networkmod.arp(expand=True) == [ + { + "ip": "203.0.113.1", + "mac": "00:00:5e:00:53:01", + "dev": "e1000g0", + "state": None, + }, + ] + + +def test_arp_expand_openbsd(): + """ + arp(expand=True) parses the OpenBSD table, taking the device from the + Netif column and skipping the header and incomplete entries. + """ + arp_out = ( + "Host Ethernet Address Netif Expire Flags\n" + "203.0.113.1 00:00:5e:00:53:01 em0 19m56s\n" + "203.0.113.9 (incomplete) em0 expired\n" + ) + with patch.dict(networkmod.__grains__, {"kernel": "OpenBSD"}), patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value=arp_out)} + ), patch("salt.utils.path.which", MagicMock(return_value="/usr/sbin/arp")): + assert networkmod.arp(expand=True) == [ + { + "ip": "203.0.113.1", + "mac": "00:00:5e:00:53:01", + "dev": "em0", + "state": None, + }, + ] + + +def test_arp_expand_aix(): + """ + arp(expand=True) parses the AIX table; AIX arp -an does not report the + interface, so dev is None. + """ + arp_out = ( + "? (203.0.113.1) at 0:0:5e:0:53:1 [ethernet] stored in bucket 4\n" + "There are 1 entries in the arp table.\n" + ) + with patch.dict(networkmod.__grains__, {"kernel": "AIX"}), patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value=arp_out)} + ), patch("salt.utils.path.which", MagicMock(return_value="/usr/sbin/arp")): + assert networkmod.arp(expand=True) == [ + { + "ip": "203.0.113.1", + "mac": "0:0:5e:0:53:1", + "dev": None, + "state": None, + }, + ] + + +def test_arp_default_warns_and_collapses(): + """ + Calling arp() without expand emits the deprecation warning and returns + the legacy flat mapping, in which entries sharing a MAC collapse to the + last one parsed. + """ + arp_out = ( + "? (203.0.113.1) at 00:00:5e:00:53:01 [ether] on eth0\n" + "? (203.0.113.9) at 00:00:5e:00:53:01 [ether] on eth0\n" + ) + with patch.dict(networkmod.__grains__, {"kernel": "Linux"}), patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value=arp_out)} + ), patch("salt.utils.path.which", MagicMock(return_value="/usr/sbin/arp")): + with pytest.warns(DeprecationWarning, match="network.arp"): + result = networkmod.arp() + assert result == {"00:00:5e:00:53:01": "203.0.113.9"} + + +def test_arp_expand_false_does_not_warn(): + """ + Passing expand=False explicitly keeps the legacy shape without emitting + the deprecation warning. + """ + arp_out = "? (203.0.113.1) at 00:00:5e:00:53:01 [ether] on eth0\n" + with patch.dict(networkmod.__grains__, {"kernel": "Linux"}), patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value=arp_out)} + ), patch("salt.utils.path.which", MagicMock(return_value="/usr/sbin/arp")): + with warnings.catch_warnings(): + warnings.simplefilter("error") + result = networkmod.arp(expand=False) + assert result == {"00:00:5e:00:53:01": "203.0.113.1"} + + +def test_interfaces(): + """ + Test for return a dictionary of information about + all the interfaces on the minion + """ + with patch.dict( + networkmod.__utils__, {"network.interfaces": MagicMock(return_value={})} + ): + assert networkmod.interfaces() == {} + + +def test_hw_addr(): + """ + Test for return the hardware address (a.k.a. MAC address) + for a given interface + """ + with patch.dict( + networkmod.__utils__, {"network.hw_addr": MagicMock(return_value={})} + ): + assert networkmod.hw_addr("iface") == {} + + +def test_interface(): + """ + Test for return the inet address for a given interface + """ + with patch.dict( + networkmod.__utils__, {"network.interface": MagicMock(return_value={})} + ): + assert networkmod.interface("iface") == {} + + +def test_interface_ip(): + """ + Test for return the inet address for a given interface + """ + with patch.dict( + networkmod.__utils__, {"network.interface_ip": MagicMock(return_value={})} + ): + assert networkmod.interface_ip("iface") == {} + + +def test_subnets(): + """ + Test for returns a list of subnets to which the host belongs + """ + with patch.dict( + networkmod.__utils__, {"network.subnets": MagicMock(return_value={})} + ): + assert networkmod.subnets() == {} + + +def test_in_subnet(): + """ + Test for returns True if host is within specified + subnet, otherwise False. + """ + with patch.dict( + networkmod.__utils__, {"network.in_subnet": MagicMock(return_value={})} + ): + assert networkmod.in_subnet("iface") == {} + + +def test_ip_addrs(): + """ + Test for returns a list of IPv4 addresses assigned to the host. + """ + with patch.dict( + networkmod.__utils__, + { + "network.ip_addrs": MagicMock(return_value=["0.0.0.0"]), + "network.in_subnet": MagicMock(return_value=True), + }, + ): + assert networkmod.ip_addrs("interface", "include_loopback", "cidr") == [ + "0.0.0.0" + ] + assert networkmod.ip_addrs("interface", "include_loopback") == ["0.0.0.0"] + + +def test_ip_addrs6(): + """ + Test for returns a list of IPv6 addresses assigned to the host. + """ + with patch.dict( + networkmod.__utils__, {"network.ip_addrs6": MagicMock(return_value=["A"])} + ): + assert networkmod.ip_addrs6("int", "include") == ["A"] + + +def test_get_hostname(): + """ + Test for Get hostname + """ + with patch.object(socket, "gethostname", return_value="A"): + assert networkmod.get_hostname() == "A" + + +def test_mod_hostname(): + """ + Test for Modify hostname + """ + assert not networkmod.mod_hostname(None) + file_d = "\n".join(["#", "A B C D,E,F G H"]) + + with patch.dict( + networkmod.__utils__, + { + "path.which": MagicMock(return_value="hostname"), + "files.fopen": mock_open(read_data=file_d), + }, + ), patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value=None)} + ), patch.dict( + networkmod.__grains__, {"os_family": "A"} + ): + assert networkmod.mod_hostname("hostname") + + +def test_mod_hostname_quoted(): + """ + Test for correctly quoted hostname on rh-style distro + """ + + fopen_mock = mock_open( + read_data={ + "/etc/hosts": "\n".join( + ["127.0.0.1 localhost.localdomain", "127.0.0.2 undef"] + ), + "/etc/sysconfig/network": "\n".join(["NETWORKING=yes", 'HOSTNAME="undef"']), + } + ) + + with patch.dict(networkmod.__grains__, {"os_family": "RedHat"}), patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value=None)} + ), patch("socket.getfqdn", MagicMock(return_value="undef")), patch.dict( + networkmod.__utils__, + { + "path.which": MagicMock(return_value="hostname"), + "files.fopen": fopen_mock, + }, + ): + assert networkmod.mod_hostname("hostname") + assert ( + fopen_mock.filehandles["/etc/sysconfig/network"][1].write_calls[1] + == 'HOSTNAME="hostname"\n' + ) + + +def test_mod_hostname_unquoted(): + """ + Test for correctly unquoted hostname on rh-style distro + """ + + fopen_mock = mock_open( + read_data={ + "/etc/hosts": "\n".join( + ["127.0.0.1 localhost.localdomain", "127.0.0.2 undef"] + ), + "/etc/sysconfig/network": "\n".join(["NETWORKING=yes", "HOSTNAME=undef"]), + } + ) + + with patch.dict(networkmod.__grains__, {"os_family": "RedHat"}), patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value=None)} + ), patch("socket.getfqdn", MagicMock(return_value="undef")), patch.dict( + networkmod.__utils__, + { + "path.which": MagicMock(return_value="hostname"), + "files.fopen": fopen_mock, + }, + ): + assert networkmod.mod_hostname("hostname") + assert ( + fopen_mock.filehandles["/etc/sysconfig/network"][1].write_calls[1] + == "HOSTNAME=hostname\n" + ) + + +def test_connect(): + """ + Test for Test connectivity to a host using a particular + port from the minion. + """ + with patch("socket.socket") as mock_socket: + assert networkmod.connect(False, "port") == { + "comment": "Required argument, host, is missing.", + "result": False, + } + assert networkmod.connect("host", False) == { + "comment": "Required argument, port, is missing.", + "result": False, + } + + ret = "Unable to connect to host (0) on tcp port port" + mock_socket.side_effect = Exception("foo") + with patch.dict( + networkmod.__utils__, + {"network.sanitize_host": MagicMock(return_value="A")}, + ): + with patch.object( + socket, + "getaddrinfo", + return_value=[["ipv4", "A", 6, "B", "0.0.0.0"]], + ): + assert networkmod.connect("host", "port") == { + "comment": ret, + "result": False, + } + + ret = "Successfully connected to host (0) on tcp port port" + mock_socket.side_effect = MagicMock() + mock_socket.settimeout().return_value = None + mock_socket.connect().return_value = None + mock_socket.shutdown().return_value = None + with patch.dict( + networkmod.__utils__, + {"network.sanitize_host": MagicMock(return_value="A")}, + ): + with patch.object( + socket, + "getaddrinfo", + return_value=[["ipv4", "A", 6, "B", "0.0.0.0"]], + ): + assert networkmod.connect("host", "port") == { + "comment": ret, + "result": True, + } + + +def test_is_private(): + """ + Test for Check if the given IP address is a private address + """ + with patch.object(ipaddress.IPv4Address, "is_private", return_value=True): + assert networkmod.is_private("0.0.0.0") + with patch.object(ipaddress.IPv6Address, "is_private", return_value=True): + assert networkmod.is_private("::1") + + +def test_is_loopback(): + """ + Test for Check if the given IP address is a loopback address + """ + with patch.object(ipaddress.IPv4Address, "is_loopback", return_value=True): + assert networkmod.is_loopback("127.0.0.1") + with patch.object(ipaddress.IPv6Address, "is_loopback", return_value=True): + assert networkmod.is_loopback("::1") + + +def test_get_bufsize(): + """ + Test for return network buffer sizes as a dict + """ + with patch.dict(networkmod.__grains__, {"kernel": "Linux"}): + with patch.object(os.path, "exists", return_value=True): + with patch.object( + networkmod, "_get_bufsize_linux", return_value={"size": 1} + ): + assert networkmod.get_bufsize("iface") == {"size": 1} + + with patch.dict(networkmod.__grains__, {"kernel": "A"}): + assert networkmod.get_bufsize("iface") == {} + + +def test_mod_bufsize(): + """ + Test for Modify network interface buffers (currently linux only) + """ + with patch.dict(networkmod.__grains__, {"kernel": "Linux"}): + with patch.object(os.path, "exists", return_value=True): + with patch.object( + networkmod, "_mod_bufsize_linux", return_value={"size": 1} + ): + assert networkmod.mod_bufsize("iface") == {"size": 1} + + with patch.dict(networkmod.__grains__, {"kernel": "A"}): + assert not networkmod.mod_bufsize("iface") + + +def test_routes(): + """ + Test for return currently configured routes from routing table + """ + pytest.raises(CommandExecutionError, networkmod.routes, "family") + + with patch.dict(networkmod.__grains__, {"kernel": "A", "os": "B"}): + pytest.raises(CommandExecutionError, networkmod.routes, "inet") + + with patch.dict(networkmod.__grains__, {"kernel": "Linux"}): + with patch.object( + networkmod, + "_netstat_route_linux", + side_effect=["A", [{"addr_family": "inet"}]], + ): + with patch.object( + networkmod, + "_ip_route_linux", + side_effect=["A", [{"addr_family": "inet"}]], + ): + assert networkmod.routes(None) == "A" + assert networkmod.routes("inet") == [{"addr_family": "inet"}] + + +def test_default_route(): + """ + Test for return default route(s) from routing table + """ + pytest.raises(CommandExecutionError, networkmod.default_route, "family") + + with patch.object( + networkmod, + "routes", + side_effect=[[{"addr_family": "inet"}, {"destination": "A"}], []], + ): + with patch.dict(networkmod.__grains__, {"kernel": "A", "os": "B"}): + pytest.raises(CommandExecutionError, networkmod.default_route, "inet") + + with patch.dict(networkmod.__grains__, {"kernel": "Linux"}): + assert networkmod.default_route("inet") == [] + + +def test_default_route_ipv6(): + """ + Test for return default route(s) from routing table for IPv6 + Additionally tests that multicast, anycast, etc. do not throw errors + """ + mock_iproute_ipv4 = """default via 192.168.0.1 dev enx3c18a040229d proto dhcp metric 100 +default via 192.168.0.1 dev wlp59s0 proto dhcp metric 600 +3.15.90.221 via 10.16.119.224 dev gpd0 +3.18.18.213 via 10.16.119.224 dev gpd0 +10.0.0.0/8 via 10.16.119.224 dev gpd0 +10.1.0.0/16 via 10.12.240.1 dev tun0 +10.2.0.0/16 via 10.12.240.1 dev tun0 +10.12.0.0/16 via 10.12.240.1 dev tun0 +10.12.240.0/20 dev tun0 proto kernel scope link src 10.12.240.2 +10.14.0.0/16 via 10.12.240.1 dev tun0 +10.16.0.0/16 via 10.12.240.1 dev tun0 +10.16.188.201 via 10.16.119.224 dev gpd0 +10.16.188.202 via 10.16.119.224 dev gpd0 +10.27.0.0/16 via 10.12.240.1 dev tun0 +52.14.149.204 via 10.16.119.224 dev gpd0 +52.14.159.171 via 10.16.119.224 dev gpd0 +52.14.249.61 via 10.16.119.224 dev gpd0 +52.15.65.251 via 10.16.119.224 dev gpd0 +54.70.229.135 via 10.16.119.224 dev gpd0 +54.71.37.253 via 10.12.240.1 dev tun0 +54.189.240.227 via 10.16.119.224 dev gpd0 +66.170.96.2 via 192.168.0.1 dev enx3c18a040229d +80.169.184.191 via 10.16.119.224 dev gpd0 +107.154.251.105 via 10.16.119.224 dev gpd0 +168.61.48.213 via 10.16.119.224 dev gpd0 +169.254.0.0/16 dev enx3c18a040229d scope link metric 1000 +172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1 linkdown +172.30.0.0/16 via 10.12.240.1 dev tun0 +184.169.136.236 via 10.16.119.224 dev gpd0 +191.237.22.167 via 10.16.119.224 dev gpd0 +192.30.68.16 via 10.16.119.224 dev gpd0 +192.30.71.16 via 10.16.119.224 dev gpd0 +192.30.71.71 via 10.16.119.224 dev gpd0 +192.168.0.0/24 dev enx3c18a040229d proto kernel scope link src 192.168.0.99 metric 100 +192.168.0.0/24 dev wlp59s0 proto kernel scope link src 192.168.0.99 metric 600 +192.240.157.233 via 10.16.119.224 dev gpd0 +206.80.50.33 via 10.16.119.224 dev gpd0 +209.34.94.97 via 10.16.119.224 dev gpd0 +unreachable should ignore this +""" + mock_iproute_ipv6 = """::1 dev lo proto kernel metric 256 pref medium +2060:123:4069::10 dev enp5s0 proto kernel metric 100 pref medium +2060:123:4069::68 dev wlp3s0 proto kernel metric 600 pref medium +2060:123:4069::15:0/112 dev virbr0 proto kernel metric 256 pref medium +2060:123:4069::/64 dev enp5s0 proto ra metric 100 pref medium +2060:123:4069::/64 dev wlp3s0 proto ra metric 600 pref medium +2602:ae13:dc4:1b00::/56 via 2602:ae14:9e1:6080::10:1 dev tun0 proto static metric 50 pref medium +2602:ae14:66:8300::/56 via 2602:ae14:9e1:6080::10:1 dev tun0 proto static metric 50 pref medium +2602:ae14:a0:4d00::/56 via 2602:ae14:9e1:6080::10:1 dev tun0 proto static metric 50 pref medium +2602:ae14:508:3900::/56 via 2602:ae14:9e1:6080::10:1 dev tun0 proto static metric 50 pref medium +2602:ae14:513:a200::/56 via 2602:ae14:9e1:6080::10:1 dev tun0 proto static metric 50 pref medium +2602:ae14:769:2b00::/56 via 2602:ae14:9e1:6080::10:1 dev tun0 proto static metric 50 pref medium +2602:ae14:924:9700::/56 via 2602:ae14:9e1:6080::10:1 dev tun0 proto static metric 50 pref medium +2602:ae14:9e1:6000::10:1 via fe80::222:15ff:fe3f:23fe dev enp5s0 proto static metric 100 pref medium +2602:ae14:9e1:6080::10:1 dev tun0 proto kernel metric 50 pref medium +2602:ae14:9e1:6080::10:1 dev tun0 proto kernel metric 256 pref medium +2602:ae14:9e1:6080::10:1001 dev tun0 proto kernel metric 50 pref medium +2602:ae14:9e1:6000::/56 via 2602:ae14:9e1:6080::10:1 dev tun0 proto static metric 50 pref medium +2602:ae14:cc1:fa00::/56 via 2602:ae14:9e1:6080::10:1 dev tun0 proto static metric 50 pref medium +2602:ae14:cd0:5b00::/56 via 2602:ae14:9e1:6080::10:1 dev tun0 proto static metric 50 pref medium +2602:ae14:d5f:b400::/56 via 2602:ae14:9e1:6080::10:1 dev tun0 proto static metric 50 pref medium +2a34:d014:1d3:5d00::/56 via 2602:ae14:9e1:6080::10:1 dev tun0 proto static metric 50 pref medium +2a34:d014:919:bb00::/56 via 2602:ae14:9e1:6080::10:1 dev tun0 proto static metric 50 pref medium +fd0d:3ed3:cb42:1::/64 dev enp5s0 proto ra metric 100 pref medium +fd0d:3ed3:cb42:1::/64 dev wlp3s0 proto ra metric 600 pref medium +fe80::222:15ff:fe3f:23fe dev enp5s0 proto static metric 100 pref medium +fe80::/64 dev enp5s0 proto kernel metric 100 pref medium +fe80::/64 dev virbr0 proto kernel metric 256 pref medium +fe80::/64 dev vnet2 proto kernel metric 256 pref medium +fe80::/64 dev docker0 proto kernel metric 256 linkdown pref medium +fe80::/64 dev vpn0 proto kernel metric 256 pref medium +fe80::/64 dev wlp3s0 proto kernel metric 600 pref medium +default via fe80::222:15ff:fe3f:23fe dev enp5s0 proto ra metric 100 pref medium +default via fe80::222:15ff:fe3f:23fe dev wlp3s0 proto ra metric 600 pref medium +local ::1 dev lo table local proto kernel metric 0 pref medium +anycast 2060:123:4069:: dev wlp3s0 table local proto kernel metric 0 pref medium +local 2060:123:4069::10 dev enp5s0 table local proto kernel metric 0 pref medium +local 2060:123:4069::68 dev wlp3s0 table local proto kernel metric 0 pref medium +anycast 2060:123:4069::15:0 dev virbr0 table local proto kernel metric 0 pref medium +local 2060:123:4069::15:1 dev virbr0 table local proto kernel metric 0 pref medium +local 2060:123:4069:0:f4d:7d09:358c:ce5 dev wlp3s0 table local proto kernel metric 0 pref medium +local 2060:123:4069:0:a089:c284:32a8:9536 dev enp5s0 table local proto kernel metric 0 pref medium +anycast 2602:ae14:9e1:6080::10:0 dev tun0 table local proto kernel metric 0 pref medium +local 2602:ae14:9e1:6080::10:1001 dev tun0 table local proto kernel metric 0 pref medium +anycast fd0d:3ed3:cb42:1:: dev wlp3s0 table local proto kernel metric 0 pref medium +local fd0d:3ed3:cb42:1:cffd:9b03:c50:6d2a dev wlp3s0 table local proto kernel metric 0 pref medium +local fd0d:3ed3:cb42:1:f00b:50ef:2143:36cf dev enp5s0 table local proto kernel metric 0 pref medium +anycast fe80:: dev virbr0 table local proto kernel metric 0 pref medium +anycast fe80:: dev vnet2 table local proto kernel metric 0 pref medium +anycast fe80:: dev docker0 table local proto kernel metric 0 pref medium +anycast fe80:: dev wlp3s0 table local proto kernel metric 0 pref medium +anycast fe80:: dev vpn0 table local proto kernel metric 0 pref medium +local fe80::42:bfff:fec9:f590 dev docker0 table local proto kernel metric 0 pref medium +local fe80::18b1:cf8e:49cc:a783 dev wlp3s0 table local proto kernel metric 0 pref medium +local fe80::5054:ff:fe55:9457 dev virbr0 table local proto kernel metric 0 pref medium +local fe80::d251:c2a7:f5c8:2778 dev enp5s0 table local proto kernel metric 0 pref medium +local fe80::df35:e22c:f7db:a892 dev vpn0 table local proto kernel metric 0 pref medium +local fe80::fc54:ff:fee6:9fef dev vnet2 table local proto kernel metric 0 pref medium +multicast ff00::/8 dev enp5s0 table local proto kernel metric 256 pref medium +multicast ff00::/8 dev virbr0 table local proto kernel metric 256 pref medium +multicast ff00::/8 dev vnet2 table local proto kernel metric 256 pref medium +multicast ff00::/8 dev docker0 table local proto kernel metric 256 linkdown pref medium +multicast ff00::/8 dev wlp3s0 table local proto kernel metric 256 pref medium +multicast ff00::/8 dev vpn0 table local proto kernel metric 256 pref medium +multicast ff00::/8 dev tun0 table local proto kernel metric 256 pref medium +unicast should ignore this +broadcast cast should ignore this +throw should ignore this +unreachable should ignore this +prohibit should ignore this +blackhole should ignore this +nat should ignore this +""" + + pytest.raises(CommandExecutionError, networkmod.default_route, "family") + + with patch.object( + networkmod, + "routes", + side_effect=[[{"family": "inet6"}, {"destination": "A"}], []], + ): + with patch.dict(networkmod.__grains__, {"kernel": "A", "os": "B"}): + pytest.raises(CommandExecutionError, networkmod.default_route, "inet6") + + cmd_mock = MagicMock(side_effect=[mock_iproute_ipv4, mock_iproute_ipv6]) + with patch.dict(networkmod.__grains__, {"kernel": "Linux"}): + with patch.dict( + networkmod.__utils__, {"path.which": MagicMock(return_value=False)} + ): + with patch.dict(networkmod.__salt__, {"cmd.run": cmd_mock}): + assert networkmod.default_route("inet6") == [ + { + "addr_family": "inet6", + "destination": "::/0", + "gateway": "fe80::222:15ff:fe3f:23fe", + "netmask": "", + "flags": "UG", + "interface": "enp5s0", + }, + { + "addr_family": "inet6", + "destination": "::/0", + "gateway": "fe80::222:15ff:fe3f:23fe", + "netmask": "", + "flags": "UG", + "interface": "wlp3s0", + }, + ] + + +def test_get_route(): + """ + Test for return output from get_route + """ + mock_iproute = MagicMock( + return_value="8.8.8.8 via 10.10.10.1 dev eth0 src 10.10.10.10 uid 0\ncache" + ) + with patch.dict(networkmod.__grains__, {"kernel": "Linux"}): + with patch.dict(networkmod.__salt__, {"cmd.run": mock_iproute}): + expected = { + "interface": "eth0", + "source": "10.10.10.10", + "destination": "8.8.8.8", + "gateway": "10.10.10.1", + } + ret = networkmod.get_route("8.8.8.8") + assert ret == expected + + mock_iproute = MagicMock( + return_value=("8.8.8.8 via 10.10.10.1 dev eth0.1 src 10.10.10.10 uid 0\ncache") + ) + with patch.dict(networkmod.__grains__, {"kernel": "Linux"}): + with patch.dict(networkmod.__salt__, {"cmd.run": mock_iproute}): + expected = { + "interface": "eth0.1", + "source": "10.10.10.10", + "destination": "8.8.8.8", + "gateway": "10.10.10.1", + } + ret = networkmod.get_route("8.8.8.8") + assert ret == expected + + mock_iproute = MagicMock( + return_value=("8.8.8.8 via 10.10.10.1 dev eth0:1 src 10.10.10.10 uid 0\ncache") + ) + with patch.dict(networkmod.__grains__, {"kernel": "Linux"}): + with patch.dict(networkmod.__salt__, {"cmd.run": mock_iproute}): + expected = { + "interface": "eth0:1", + "source": "10.10.10.10", + "destination": "8.8.8.8", + "gateway": "10.10.10.1", + } + ret = networkmod.get_route("8.8.8.8") + assert ret == expected + + mock_iproute = MagicMock( + return_value=("8.8.8.8 via 10.10.10.1 dev lan-br0 src 10.10.10.10 uid 0\ncache") + ) + with patch.dict(networkmod.__grains__, {"kernel": "Linux"}): + with patch.dict(networkmod.__salt__, {"cmd.run": mock_iproute}): + expected = { + "interface": "lan-br0", + "source": "10.10.10.10", + "destination": "8.8.8.8", + "gateway": "10.10.10.1", + } + ret = networkmod.get_route("8.8.8.8") + assert ret == expected + + +@pytest.mark.skip_on_windows(reason="ip neigh not available in Windows") +def test_ip_neighs(): + """ + Test for return the ip neigh table for IPv4 addresses from the minion + """ + mock_ipv4_neighbor = """192.168.0.67 dev enp0s3 lladdr b4:22:00:27:d4:75 STALE +192.168.0.107 dev enp0s3 lladdr 3c:18:a0:40:22:9d REACHABLE +192.168.0.103 dev enp0s3 lladdr d0:c2:4e:a0:dd:17 STALE +192.168.0.106 dev enp0s3 BAD +192.168.0.1 dev enp0s3 lladdr 9c:97:26:18:c4:1f DELAY +ff80::725:53ff:fe3d:10be dev eth1 BAD +fe80::825:63ff:fe2d:19be dev eth0 lladdr 0a:25:63:2d:19:be router STALE + """ + expected = { + "3c:18:a0:40:22:9d": "192.168.0.107", + "9c:97:26:18:c4:1f": "192.168.0.1", + "b4:22:00:27:d4:75": "192.168.0.67", + "d0:c2:4e:a0:dd:17": "192.168.0.103", + } + + with patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value=mock_ipv4_neighbor)} + ): + result = networkmod.ip_neighs(expand=False) + assert result == expected + + +@pytest.mark.skip_on_windows(reason="ip neigh not available in Windows") +def test_ip_neighs_expand(): + """ + ip_neighs(expand=True) returns entry dicts carrying the interface and + neighbour state, preserving multiple IPv4 addresses that share a MAC. + """ + mock_ipv4_neighbor = """203.0.113.1 dev eth0 lladdr 00:00:5e:00:53:01 REACHABLE +203.0.113.9 dev eth0 lladdr 00:00:5e:00:53:01 STALE +203.0.113.42 dev eth1 lladdr 00:00:5e:00:53:2a DELAY +203.0.113.66 dev eth0 FAILED +2001:db8::1 dev eth0 lladdr 00:00:5e:00:53:01 router REACHABLE + """ + with patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value=mock_ipv4_neighbor)} + ): + assert networkmod.ip_neighs(expand=True) == [ + { + "ip": "203.0.113.1", + "mac": "00:00:5e:00:53:01", + "dev": "eth0", + "state": "REACHABLE", + }, + { + "ip": "203.0.113.9", + "mac": "00:00:5e:00:53:01", + "dev": "eth0", + "state": "STALE", + }, + { + "ip": "203.0.113.42", + "mac": "00:00:5e:00:53:2a", + "dev": "eth1", + "state": "DELAY", + }, + ] + + +@pytest.mark.skip_on_windows(reason="ip neigh not available in Windows") +def test_ip_neighs_default_warns(): + """ + Calling ip_neighs() without expand emits the deprecation warning and + returns the legacy flat mapping. + """ + mock_neighbor = "203.0.113.1 dev eth0 lladdr 00:00:5e:00:53:01 REACHABLE" + with patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value=mock_neighbor)} + ): + with pytest.warns(DeprecationWarning, match="network.ip_neighs"): + result = networkmod.ip_neighs() + assert result == {"00:00:5e:00:53:01": "203.0.113.1"} + + +@pytest.mark.skip_on_windows(reason="ip neigh not available in Windows") +def test_ip_neighs6(): + """ + Test for return the ip neigh table for IPv6 addresses from the minion + """ + mock_ipv6_neighbor = """10.27.56.1 dev eth0 lladdr 0a:25:63:2d:19:be DELAY +192.168.0.103 dev enp0s3 lladdr d0:c2:4e:a0:dd:17 STALE +192.168.0.106 dev enp0s3 BAD +ff80::725:53ff:fe3d:10be dev eth1 BAD +fe80::825:63ff:fe2d:19be dev eth0 lladdr 0a:25:63:2d:19:be router STALE + """ + expected = {"0a:25:63:2d:19:be": "fe80::825:63ff:fe2d:19be"} + + with patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value=mock_ipv6_neighbor)} + ): + result = networkmod.ip_neighs6(expand=False) + assert result == expected + + +@pytest.mark.skip_on_windows(reason="ip neigh not available in Windows") +def test_ip_neighs6_expand(): + """ + ip_neighs6(expand=True) preserves the link-local and global addresses a + host holds on the same MAC, which the legacy flat mapping collapses to a + single arbitrary entry. + """ + mock_ipv6_neighbor = """2001:db8::1 dev eth0 lladdr 00:00:5e:00:53:01 router REACHABLE +fe80::200:5eff:fe00:5301 dev eth0 lladdr 00:00:5e:00:53:01 router REACHABLE +2001:db8::52 dev eth0 lladdr 00:00:5e:00:53:52 REACHABLE +fe80::200:5eff:fe00:5352 dev eth0 lladdr 00:00:5e:00:53:52 STALE +203.0.113.1 dev eth0 lladdr 00:00:5e:00:53:01 REACHABLE + """ + with patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value=mock_ipv6_neighbor)} + ): + expanded = networkmod.ip_neighs6(expand=True) + assert expanded == [ + { + "ip": "2001:db8::1", + "mac": "00:00:5e:00:53:01", + "dev": "eth0", + "state": "REACHABLE", + }, + { + "ip": "fe80::200:5eff:fe00:5301", + "mac": "00:00:5e:00:53:01", + "dev": "eth0", + "state": "REACHABLE", + }, + { + "ip": "2001:db8::52", + "mac": "00:00:5e:00:53:52", + "dev": "eth0", + "state": "REACHABLE", + }, + { + "ip": "fe80::200:5eff:fe00:5352", + "mac": "00:00:5e:00:53:52", + "dev": "eth0", + "state": "STALE", + }, + ] + # The legacy shape drops half of these neighbours. + flat = networkmod.ip_neighs6(expand=False) + assert len(flat) == 2 + + +@pytest.mark.skip_on_windows(reason="ip neigh not available in Windows") +def test_ip_neighs6_default_warns(): + """ + Calling ip_neighs6() without expand emits the deprecation warning and + returns the legacy flat mapping. + """ + mock_neighbor = "2001:db8::1 dev eth0 lladdr 00:00:5e:00:53:01 REACHABLE" + with patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value=mock_neighbor)} + ): + with pytest.warns(DeprecationWarning, match="network.ip_neighs6"): + result = networkmod.ip_neighs6() + assert result == {"00:00:5e:00:53:01": "2001:db8::1"} diff --git a/tests/pytests/unit/modules/test_win_network.py b/tests/pytests/unit/modules/test_win_network.py index 524bc198e2f7..b8ddd33e19f8 100644 --- a/tests/pytests/unit/modules/test_win_network.py +++ b/tests/pytests/unit/modules/test_win_network.py @@ -3,6 +3,7 @@ """ import socket +import warnings import pytest @@ -295,3 +296,157 @@ def test_connect_53371(): rtn["comment"] == "Unable to connect to test-server (unknown) on tcp port 80" ) + + +def test_arp_expand(): + """ + arp(expand=True) maps Get-NetNeighbor objects to entry dicts, skipping + unresolved neighbours and normalizing MAC addresses to the lowercase + colon-separated form used by the Unix network module. + """ + neighbors = [ + { + "IPAddress": "203.0.113.1", + "LinkLayerAddress": "00-00-5E-00-53-01", + "InterfaceAlias": "Ethernet0", + "State": "Reachable", + }, + { + "IPAddress": "203.0.113.9", + "LinkLayerAddress": "00-00-5E-00-53-01", + "InterfaceAlias": "Ethernet0", + "State": "Stale", + }, + { + "IPAddress": "203.0.113.66", + "LinkLayerAddress": "", + "InterfaceAlias": "Ethernet0", + "State": "Unreachable", + }, + ] + mock_powershell = MagicMock(return_value=neighbors) + with patch.dict(win_network.__salt__, {"cmd.powershell": mock_powershell}): + assert win_network.arp(expand=True) == [ + { + "ip": "203.0.113.1", + "mac": "00:00:5e:00:53:01", + "dev": "Ethernet0", + "state": "Reachable", + }, + { + "ip": "203.0.113.9", + "mac": "00:00:5e:00:53:01", + "dev": "Ethernet0", + "state": "Stale", + }, + ] + assert "-AddressFamily IPv4" in mock_powershell.call_args[0][0] + + +def test_arp_default_warns_and_collapses(): + """ + Calling arp() without expand emits the deprecation warning and returns + the legacy flat mapping, in which entries sharing a MAC collapse to the + last one returned. + """ + neighbors = [ + { + "IPAddress": "203.0.113.1", + "LinkLayerAddress": "00-00-5E-00-53-01", + "InterfaceAlias": "Ethernet0", + "State": "Reachable", + }, + { + "IPAddress": "203.0.113.9", + "LinkLayerAddress": "00-00-5E-00-53-01", + "InterfaceAlias": "Ethernet0", + "State": "Stale", + }, + ] + with patch.dict( + win_network.__salt__, {"cmd.powershell": MagicMock(return_value=neighbors)} + ): + with pytest.warns(DeprecationWarning, match="network.arp"): + result = win_network.arp() + assert result == {"00:00:5e:00:53:01": "203.0.113.9"} + + +def test_ip_neighs_single_neighbor(): + """ + A single neighbour serializes to a bare object instead of a list; + ip_neighs handles both. + """ + neighbor = { + "IPAddress": "203.0.113.1", + "LinkLayerAddress": "00-00-5E-00-53-01", + "InterfaceAlias": "Ethernet0", + "State": "Reachable", + } + with patch.dict( + win_network.__salt__, {"cmd.powershell": MagicMock(return_value=neighbor)} + ): + assert win_network.ip_neighs(expand=False) == { + "00:00:5e:00:53:01": "203.0.113.1" + } + + +def test_ip_neighs6_expand(): + """ + ip_neighs6 queries the IPv6 address family and preserves the link-local + and global addresses a host holds on the same MAC, which the legacy flat + mapping collapses. + """ + neighbors = [ + { + "IPAddress": "2001:db8::52", + "LinkLayerAddress": "00-00-5E-00-53-52", + "InterfaceAlias": "Ethernet0", + "State": "Reachable", + }, + { + "IPAddress": "fe80::200:5eff:fe00:5352", + "LinkLayerAddress": "00-00-5E-00-53-52", + "InterfaceAlias": "Ethernet0", + "State": "Stale", + }, + ] + mock_powershell = MagicMock(return_value=neighbors) + with patch.dict(win_network.__salt__, {"cmd.powershell": mock_powershell}): + expanded = win_network.ip_neighs6(expand=True) + assert expanded == [ + { + "ip": "2001:db8::52", + "mac": "00:00:5e:00:53:52", + "dev": "Ethernet0", + "state": "Reachable", + }, + { + "ip": "fe80::200:5eff:fe00:5352", + "mac": "00:00:5e:00:53:52", + "dev": "Ethernet0", + "state": "Stale", + }, + ] + # The legacy shape drops one of the two addresses. + assert len(win_network.ip_neighs6(expand=False)) == 1 + assert "-AddressFamily IPv6" in mock_powershell.call_args[0][0] + + +def test_ip_neighs_expand_false_does_not_warn(): + """ + Passing expand=False explicitly keeps the legacy shape without emitting + the deprecation warning. + """ + neighbor = { + "IPAddress": "203.0.113.1", + "LinkLayerAddress": "00-00-5E-00-53-01", + "InterfaceAlias": "Ethernet0", + "State": "Reachable", + } + with patch.dict( + win_network.__salt__, {"cmd.powershell": MagicMock(return_value=neighbor)} + ): + with warnings.catch_warnings(): + warnings.simplefilter("error") + result = win_network.ip_neighs(expand=False) + assert result == {"00:00:5e:00:53:01": "203.0.113.1"} diff --git a/tests/unit/modules/test_network.py b/tests/unit/modules/test_network.py index 34b06250fc6a..7c8faaf4792e 100644 --- a/tests/unit/modules/test_network.py +++ b/tests/unit/modules/test_network.py @@ -165,7 +165,7 @@ def test_arp(self): with patch.dict( network.__salt__, {"cmd.run": MagicMock(return_value="A,B,C,D\nE,F,G,H\n")} ), patch("salt.utils.path.which", MagicMock(return_value="")): - self.assertDictEqual(network.arp(), {}) + self.assertDictEqual(network.arp(expand=False), {}) def test_interfaces(self): """ From baee17c6f253a51a7f0197b6635d9408a6b8bb5f Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 2 Jul 2026 10:53:39 -0400 Subject: [PATCH 2/6] Harden neighbour parsing against unresolved entries; align Windows output Review fixes on top of the expand/deprecation change: - ip neigh lines for unresolved neighbours carry no lladdr, but a flag such as router can still pad them to five fields; the parser then took the state word as the MAC (mac='FAILED' for an unreachable IPv6 gateway). Gate on the lladdr token instead of counting fields. - arp -an prints unresolved entries with an /(incomplete) placeholder in the MAC column, which leaked into both return shapes on Linux/macOS/FreeBSD/AIX while the OpenBSD branch skipped it. Skip the placeholder everywhere; only resolved neighbours are reported now. - Windows kept the permanent multicast/broadcast pseudo-neighbours (224.0.0.0/4, ff00::/8, 255.255.255.255) that Linux never stores in its neighbour table, and reported CamelCase states; filter those pseudo-entries and report the uppercase NUD vocabulary so consumers see the same data cross-platform. - File the deprecation notice as changelog/69655.deprecated.md so towncrier renders it in the Deprecated section; the fixed fragment now covers the unresolved-entry parsing fixes. --- changelog/69655.deprecated.md | 1 + changelog/69655.fixed.md | 2 +- salt/modules/network.py | 19 +++++++--- salt/modules/win_network.py | 25 ++++++++++--- tests/pytests/unit/modules/test_network.py | 35 +++++++++++++++++-- .../pytests/unit/modules/test_win_network.py | 32 +++++++++++++---- 6 files changed, 96 insertions(+), 18 deletions(-) create mode 100644 changelog/69655.deprecated.md diff --git a/changelog/69655.deprecated.md b/changelog/69655.deprecated.md new file mode 100644 index 000000000000..b02757a54ccb --- /dev/null +++ b/changelog/69655.deprecated.md @@ -0,0 +1 @@ +Deprecated the ``{mac: ip}`` return shape of ``network.arp``, ``network.ip_neighs`` and ``network.ip_neighs6``, which silently drops neighbour entries whenever several IP addresses share a MAC address (with IPv6 this loses entries for practically every host, since neighbours normally hold at least a link-local and a global address on the same MAC). A new ``expand`` argument opts in to a list of ``{ip, mac, dev, state}`` entry dicts, which will become the default return shape in salt 3011; until then, calls without ``expand`` emit a ``DeprecationWarning``. diff --git a/changelog/69655.fixed.md b/changelog/69655.fixed.md index b02757a54ccb..2ce2ecbd8ca7 100644 --- a/changelog/69655.fixed.md +++ b/changelog/69655.fixed.md @@ -1 +1 @@ -Deprecated the ``{mac: ip}`` return shape of ``network.arp``, ``network.ip_neighs`` and ``network.ip_neighs6``, which silently drops neighbour entries whenever several IP addresses share a MAC address (with IPv6 this loses entries for practically every host, since neighbours normally hold at least a link-local and a global address on the same MAC). A new ``expand`` argument opts in to a list of ``{ip, mac, dev, state}`` entry dicts, which will become the default return shape in salt 3011; until then, calls without ``expand`` emit a ``DeprecationWarning``. +``network.arp``, ``network.ip_neighs`` and ``network.ip_neighs6`` no longer emit bogus entries for unresolved neighbours: ``arp -an`` rows whose MAC column is the ````/``(incomplete)`` placeholder, and ``ip neigh`` rows that carry a flag such as ``router`` but no ``lladdr`` (which previously leaked the state word, e.g. ``FAILED``, as the MAC key). diff --git a/salt/modules/network.py b/salt/modules/network.py index faef951daed3..2eee3317e99d 100644 --- a/salt/modules/network.py +++ b/salt/modules/network.py @@ -1136,7 +1136,8 @@ def arp(expand=None): Added the ``expand`` argument. The list-of-entries shape it enables will become the default return shape in salt 3011; until then, calling this function without ``expand`` emits a - ``DeprecationWarning``. + ``DeprecationWarning``. Unresolved (incomplete) entries are no + longer included in either shape. CLI Example: @@ -1177,6 +1178,8 @@ def arp(expand=None): elif __grains__["kernel"] == "AIX": if comps[0] in ("bucket", "There"): continue + if comps[3] in ("", "(incomplete)"): + continue entries.append( { "ip": comps[1].strip("(").strip(")"), @@ -1186,6 +1189,8 @@ def arp(expand=None): } ) else: + if comps[3] in ("", "(incomplete)"): + continue entries.append( { "ip": comps[1].strip("(").strip(")"), @@ -1429,7 +1434,11 @@ def _parse_ip_neigh(family_char): out = __salt__["cmd.run"]("ip neigh show") for line in out.splitlines(): comps = line.split() - if len(comps) < 5: + # Resolved entries always print as " dev lladdr + # [flags] ". Unresolved ones (FAILED/INCOMPLETE) carry no + # lladdr, and flag tokens such as "router" can still pad them to + # five fields, so the lladdr token is the only reliable gate. + if len(comps) < 5 or comps[3] != "lladdr": continue if family_char not in comps[0]: continue @@ -1462,7 +1471,8 @@ def ip_neighs(expand=None): Added the ``expand`` argument. The list-of-entries shape it enables will become the default return shape in salt 3011; until then, calling this function without ``expand`` emits a - ``DeprecationWarning``. + ``DeprecationWarning``. Unresolved entries (those without a + link-layer address) are no longer included in either shape. CLI Example: @@ -1501,7 +1511,8 @@ def ip_neighs6(expand=None): Added the ``expand`` argument. The list-of-entries shape it enables will become the default return shape in salt 3011; until then, calling this function without ``expand`` emits a - ``DeprecationWarning``. + ``DeprecationWarning``. Unresolved entries (those without a + link-layer address) are no longer included in either shape. CLI Example: diff --git a/salt/modules/win_network.py b/salt/modules/win_network.py index 958541bdeca3..6f8010f59c64 100644 --- a/salt/modules/win_network.py +++ b/salt/modules/win_network.py @@ -654,9 +654,13 @@ def _get_neighbors(address_family): """ Return the neighbour (ARP/NDP) table for the given address family ("IPv4" or "IPv6") as a list of entry dicts, via Get-NetNeighbor. - Unresolved entries (no link-layer address) are skipped, and MAC - addresses are normalized to the lowercase colon-separated form used by - the Unix network module. + + To match what the Unix network module can observe, unresolved entries + (no link-layer address) and the static multicast/broadcast + pseudo-neighbours Windows keeps in its cache are skipped, MAC addresses + are normalized to the lowercase colon-separated form, and states are + reported in the uppercase NUD vocabulary (REACHABLE, STALE, ...) used + by ``ip neigh``. """ cmd = ( f"Get-NetNeighbor -AddressFamily {address_family} | " @@ -674,12 +678,23 @@ def _get_neighbors(address_family): if not mac: # Unreachable/incomplete entries carry no link-layer address continue + ip_addr = neighbor.get("IPAddress") + try: + addr = ipaddress.ip_address(ip_addr) + if addr.is_multicast or ip_addr == "255.255.255.255": + # Windows keeps permanent multicast/broadcast + # pseudo-neighbours in its cache; Linux never stores these + # in the neighbour table, so skip them for parity. + continue + except ValueError: + pass + state = neighbor.get("State") entries.append( { - "ip": neighbor.get("IPAddress"), + "ip": ip_addr, "mac": mac.replace("-", ":").lower(), "dev": neighbor.get("InterfaceAlias"), - "state": neighbor.get("State"), + "state": state.upper() if state else state, } ) diff --git a/tests/pytests/unit/modules/test_network.py b/tests/pytests/unit/modules/test_network.py index 499872ba6b31..033db161b330 100644 --- a/tests/pytests/unit/modules/test_network.py +++ b/tests/pytests/unit/modules/test_network.py @@ -337,7 +337,8 @@ def test_arp_expand_aix(): """ arp_out = ( "? (203.0.113.1) at 0:0:5e:0:53:1 [ethernet] stored in bucket 4\n" - "There are 1 entries in the arp table.\n" + "? (203.0.113.9) at (incomplete) stored in bucket 5\n" + "There are 2 entries in the arp table.\n" ) with patch.dict(networkmod.__grains__, {"kernel": "AIX"}), patch.dict( networkmod.__salt__, {"cmd.run": MagicMock(return_value=arp_out)} @@ -352,6 +353,31 @@ def test_arp_expand_aix(): ] +def test_arp_skips_incomplete_entries(): + """ + Unresolved arp -an entries carry an placeholder instead of + a MAC; they are excluded from both return shapes rather than reported + with the placeholder as the MAC. + """ + arp_out = ( + "? (203.0.113.1) at 00:00:5e:00:53:01 [ether] on eth0\n" + "? (203.0.113.99) at on eth0\n" + "? (203.0.113.98) at (incomplete) on em0 expired [ethernet]\n" + ) + with patch.dict(networkmod.__grains__, {"kernel": "Linux"}), patch.dict( + networkmod.__salt__, {"cmd.run": MagicMock(return_value=arp_out)} + ), patch("salt.utils.path.which", MagicMock(return_value="/usr/sbin/arp")): + assert networkmod.arp(expand=True) == [ + { + "ip": "203.0.113.1", + "mac": "00:00:5e:00:53:01", + "dev": "eth0", + "state": None, + }, + ] + assert networkmod.arp(expand=False) == {"00:00:5e:00:53:01": "203.0.113.1"} + + def test_arp_default_warns_and_collapses(): """ Calling arp() without expand emits the deprecation warning and returns @@ -955,11 +981,14 @@ def test_ip_neighs_expand(): """ ip_neighs(expand=True) returns entry dicts carrying the interface and neighbour state, preserving multiple IPv4 addresses that share a MAC. + Unresolved entries are excluded even when a flag token such as + extern_learn pads them to five fields. """ mock_ipv4_neighbor = """203.0.113.1 dev eth0 lladdr 00:00:5e:00:53:01 REACHABLE 203.0.113.9 dev eth0 lladdr 00:00:5e:00:53:01 STALE 203.0.113.42 dev eth1 lladdr 00:00:5e:00:53:2a DELAY 203.0.113.66 dev eth0 FAILED +203.0.113.99 dev eth0 extern_learn FAILED 2001:db8::1 dev eth0 lladdr 00:00:5e:00:53:01 router REACHABLE """ with patch.dict( @@ -1027,12 +1056,14 @@ def test_ip_neighs6_expand(): """ ip_neighs6(expand=True) preserves the link-local and global addresses a host holds on the same MAC, which the legacy flat mapping collapses to a - single arbitrary entry. + single arbitrary entry. An unresolved router entry (no lladdr, but + padded to five fields by the router flag) is excluded. """ mock_ipv6_neighbor = """2001:db8::1 dev eth0 lladdr 00:00:5e:00:53:01 router REACHABLE fe80::200:5eff:fe00:5301 dev eth0 lladdr 00:00:5e:00:53:01 router REACHABLE 2001:db8::52 dev eth0 lladdr 00:00:5e:00:53:52 REACHABLE fe80::200:5eff:fe00:5352 dev eth0 lladdr 00:00:5e:00:53:52 STALE +fe80::dead dev eth0 router FAILED 203.0.113.1 dev eth0 lladdr 00:00:5e:00:53:01 REACHABLE """ with patch.dict( diff --git a/tests/pytests/unit/modules/test_win_network.py b/tests/pytests/unit/modules/test_win_network.py index b8ddd33e19f8..e97ed609110f 100644 --- a/tests/pytests/unit/modules/test_win_network.py +++ b/tests/pytests/unit/modules/test_win_network.py @@ -301,8 +301,10 @@ def test_connect_53371(): def test_arp_expand(): """ arp(expand=True) maps Get-NetNeighbor objects to entry dicts, skipping - unresolved neighbours and normalizing MAC addresses to the lowercase - colon-separated form used by the Unix network module. + unresolved neighbours and the static multicast/broadcast + pseudo-neighbours Windows keeps in its cache, normalizing MAC addresses + to the lowercase colon-separated form and states to the uppercase NUD + vocabulary used by the Unix network module. """ neighbors = [ { @@ -323,6 +325,18 @@ def test_arp_expand(): "InterfaceAlias": "Ethernet0", "State": "Unreachable", }, + { + "IPAddress": "224.0.0.22", + "LinkLayerAddress": "01-00-5E-00-00-16", + "InterfaceAlias": "Ethernet0", + "State": "Permanent", + }, + { + "IPAddress": "255.255.255.255", + "LinkLayerAddress": "FF-FF-FF-FF-FF-FF", + "InterfaceAlias": "Ethernet0", + "State": "Permanent", + }, ] mock_powershell = MagicMock(return_value=neighbors) with patch.dict(win_network.__salt__, {"cmd.powershell": mock_powershell}): @@ -331,13 +345,13 @@ def test_arp_expand(): "ip": "203.0.113.1", "mac": "00:00:5e:00:53:01", "dev": "Ethernet0", - "state": "Reachable", + "state": "REACHABLE", }, { "ip": "203.0.113.9", "mac": "00:00:5e:00:53:01", "dev": "Ethernet0", - "state": "Stale", + "state": "STALE", }, ] assert "-AddressFamily IPv4" in mock_powershell.call_args[0][0] @@ -409,6 +423,12 @@ def test_ip_neighs6_expand(): "InterfaceAlias": "Ethernet0", "State": "Stale", }, + { + "IPAddress": "ff02::16", + "LinkLayerAddress": "33-33-00-00-00-16", + "InterfaceAlias": "Ethernet0", + "State": "Permanent", + }, ] mock_powershell = MagicMock(return_value=neighbors) with patch.dict(win_network.__salt__, {"cmd.powershell": mock_powershell}): @@ -418,13 +438,13 @@ def test_ip_neighs6_expand(): "ip": "2001:db8::52", "mac": "00:00:5e:00:53:52", "dev": "Ethernet0", - "state": "Reachable", + "state": "REACHABLE", }, { "ip": "fe80::200:5eff:fe00:5352", "mac": "00:00:5e:00:53:52", "dev": "Ethernet0", - "state": "Stale", + "state": "STALE", }, ] # The legacy shape drops one of the two addresses. From 5813f867e8fea7872c1e796c94cb9b22ea1a3b9f Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Sun, 5 Jul 2026 18:16:33 -0400 Subject: [PATCH 3/6] Filter subnet-directed broadcasts from win_network neighbours Validated Get-NetNeighbor output on a real Windows Server 2025 host: it also keeps permanent subnet-directed broadcast entries (e.g. 10.0.2.255), which are neither multicast nor the limited broadcast address, so the previous filter let them through as phantom neighbours. Every broadcast/multicast pseudo-neighbour carries a group link-layer address (low bit of the first MAC octet set), while real hosts have a unicast MAC, so filter on that instead. Confirmed the real IPv4/IPv6 tables and the single-entry (bare object) serialization parse correctly. --- salt/modules/win_network.py | 24 +++++++++---------- .../pytests/unit/modules/test_win_network.py | 9 +++++++ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/salt/modules/win_network.py b/salt/modules/win_network.py index 6f8010f59c64..c8354ec5043f 100644 --- a/salt/modules/win_network.py +++ b/salt/modules/win_network.py @@ -678,21 +678,21 @@ def _get_neighbors(address_family): if not mac: # Unreachable/incomplete entries carry no link-layer address continue - ip_addr = neighbor.get("IPAddress") - try: - addr = ipaddress.ip_address(ip_addr) - if addr.is_multicast or ip_addr == "255.255.255.255": - # Windows keeps permanent multicast/broadcast - # pseudo-neighbours in its cache; Linux never stores these - # in the neighbour table, so skip them for parity. - continue - except ValueError: - pass + mac = mac.replace("-", ":").lower() + # Windows keeps permanent broadcast and multicast pseudo-neighbours in + # its cache: the limited broadcast (255.255.255.255), subnet-directed + # broadcasts (e.g. 10.0.2.255), IPv4 multicast (224.0.0.0/4) and IPv6 + # multicast (ff00::/8). Every one of these carries a group link-layer + # address -- the low bit of the first MAC octet is set -- whereas a + # real host always has a unicast MAC. Linux never stores these in its + # neighbour table, so skip them for parity. + if int(mac.split(":")[0], 16) & 1: + continue state = neighbor.get("State") entries.append( { - "ip": ip_addr, - "mac": mac.replace("-", ":").lower(), + "ip": neighbor.get("IPAddress"), + "mac": mac, "dev": neighbor.get("InterfaceAlias"), "state": state.upper() if state else state, } diff --git a/tests/pytests/unit/modules/test_win_network.py b/tests/pytests/unit/modules/test_win_network.py index e97ed609110f..ba372fd2e2a5 100644 --- a/tests/pytests/unit/modules/test_win_network.py +++ b/tests/pytests/unit/modules/test_win_network.py @@ -337,6 +337,15 @@ def test_arp_expand(): "InterfaceAlias": "Ethernet0", "State": "Permanent", }, + { + # Subnet-directed broadcast: a real Windows Server 2025 entry. It is + # neither multicast nor the limited broadcast address, so it can + # only be recognized by its broadcast link-layer address. + "IPAddress": "203.0.113.255", + "LinkLayerAddress": "FF-FF-FF-FF-FF-FF", + "InterfaceAlias": "Ethernet0", + "State": "Permanent", + }, ] mock_powershell = MagicMock(return_value=neighbors) with patch.dict(win_network.__salt__, {"cmd.powershell": mock_powershell}): From 6dd0d90e96fa0fa257f24e60c4f6c00dcfba2566 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 9 Jul 2026 23:11:45 -0400 Subject: [PATCH 4/6] Reference 3006.28 for neighbour markers now that this targets 3006.x The expand-argument versionchanged markers and the Windows neighbour-function versionadded markers pointed at 3009.0. With this PR retargeted to 3006.x, they should reference 3006.28 (the next 3006.x release). The deprecation still flips the default return shape in 3011, unchanged. --- salt/modules/network.py | 6 +++--- salt/modules/win_network.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/salt/modules/network.py b/salt/modules/network.py index 2eee3317e99d..28a111222422 100644 --- a/salt/modules/network.py +++ b/salt/modules/network.py @@ -1132,7 +1132,7 @@ def arp(expand=None): .. versionchanged:: 2015.8.0 Added support for SunOS - .. versionchanged:: 3009.0 + .. versionchanged:: 3006.28 Added the ``expand`` argument. The list-of-entries shape it enables will become the default return shape in salt 3011; until then, calling this function without ``expand`` emits a @@ -1467,7 +1467,7 @@ def ip_neighs(expand=None): .. versionadded:: 3006.0 - .. versionchanged:: 3009.0 + .. versionchanged:: 3006.28 Added the ``expand`` argument. The list-of-entries shape it enables will become the default return shape in salt 3011; until then, calling this function without ``expand`` emits a @@ -1507,7 +1507,7 @@ def ip_neighs6(expand=None): .. versionadded:: 3006.0 - .. versionchanged:: 3009.0 + .. versionchanged:: 3006.28 Added the ``expand`` argument. The list-of-entries shape it enables will become the default return shape in salt 3011; until then, calling this function without ``expand`` emits a diff --git a/salt/modules/win_network.py b/salt/modules/win_network.py index c8354ec5043f..e2a099d0512a 100644 --- a/salt/modules/win_network.py +++ b/salt/modules/win_network.py @@ -721,7 +721,7 @@ def arp(expand=None): legacy mapping can only hold one entry per MAC address, so any further IP addresses sharing that MAC are silently dropped from it. - .. versionadded:: 3009.0 + .. versionadded:: 3006.28 The list-of-entries shape enabled by ``expand=True`` will become the default return shape in salt 3011; until then, calling this function without ``expand`` emits a ``DeprecationWarning``, matching the @@ -752,7 +752,7 @@ def ip_neighs(expand=None): legacy mapping can only hold one entry per MAC address, so any further IP addresses sharing that MAC are silently dropped from it. - .. versionadded:: 3009.0 + .. versionadded:: 3006.28 The list-of-entries shape enabled by ``expand=True`` will become the default return shape in salt 3011; until then, calling this function without ``expand`` emits a ``DeprecationWarning``, matching the @@ -788,7 +788,7 @@ def ip_neighs6(expand=None): the same MAC, so the legacy mapping loses entries for practically every neighbour; ``expand=True`` is strongly recommended. - .. versionadded:: 3009.0 + .. versionadded:: 3006.28 The list-of-entries shape enabled by ``expand=True`` will become the default return shape in salt 3011; until then, calling this function without ``expand`` emits a ``DeprecationWarning``, matching the From 71f6d4018ccd02ea387f5fe82fc683e32f3224d6 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Fri, 10 Jul 2026 21:21:58 -0400 Subject: [PATCH 5/6] Extract neighbour-table helpers to salt.utils.network The neigh_expand_warning and neighs_flatten helpers were defined identically in both salt/modules/network.py and salt/modules/win_network.py. Move them to salt.utils.network as shared public functions and have both modules delegate, dropping the now-unused salt.utils.versions imports. Add direct unit tests for the extracted helpers. --- salt/modules/network.py | 41 +++----------------- salt/modules/win_network.py | 41 +++----------------- salt/utils/network.py | 30 ++++++++++++++- tests/pytests/unit/utils/test_network.py | 49 ++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 71 deletions(-) diff --git a/salt/modules/network.py b/salt/modules/network.py index 28a111222422..e97aab8d8fc5 100644 --- a/salt/modules/network.py +++ b/salt/modules/network.py @@ -17,7 +17,6 @@ import salt.utils.network import salt.utils.platform import salt.utils.validate.net -import salt.utils.versions from salt._compat import ipaddress from salt.exceptions import CommandExecutionError @@ -1088,34 +1087,6 @@ def dig(host): return __salt__["cmd.run"](cmd) -def _neigh_expand_warning(func_name, expand): - """ - Warn about the upcoming neighbour table return shape change when the - caller did not pass ``expand`` explicitly, and return the effective - value of ``expand``. - """ - if expand is None: - salt.utils.versions.warn_until( - 3011, - f"In salt 3011, {func_name} will return a list of neighbour entry " - "dicts by default instead of a mac-to-ip mapping, which silently " - "drops entries whenever several IP addresses share a MAC address. " - "Pass expand=True to opt in to the new shape now, or expand=False " - "to keep the current shape and silence this warning.", - ) - return False - return expand - - -def _neighs_flatten(entries): - """ - Flatten neighbour entry dicts into the legacy ``{mac: ip}`` shape. When - several entries share a MAC address, the last one wins, matching the - historical behaviour. - """ - return {entry["mac"]: entry["ip"] for entry in entries} - - @salt.utils.decorators.path.which("arp") def arp(expand=None): """ @@ -1146,7 +1117,7 @@ def arp(expand=None): salt '*' network.arp salt '*' network.arp expand=True """ - expand = _neigh_expand_warning("network.arp", expand) + expand = salt.utils.network.neigh_expand_warning("network.arp", expand) entries = [] out = __salt__["cmd.run"]("arp -an") for line in out.splitlines(): @@ -1202,7 +1173,7 @@ def arp(expand=None): if expand: return entries - return _neighs_flatten(entries) + return salt.utils.network.neighs_flatten(entries) def interfaces(): @@ -1481,11 +1452,11 @@ def ip_neighs(expand=None): salt '*' network.ip_neighs salt '*' network.ip_neighs expand=True """ - expand = _neigh_expand_warning("network.ip_neighs", expand) + expand = salt.utils.network.neigh_expand_warning("network.ip_neighs", expand) entries = _parse_ip_neigh(".") if expand: return entries - return _neighs_flatten(entries) + return salt.utils.network.neighs_flatten(entries) ipneighs = salt.utils.functools.alias_function(ip_neighs, "ipneighs") @@ -1521,11 +1492,11 @@ def ip_neighs6(expand=None): salt '*' network.ip_neighs6 salt '*' network.ip_neighs6 expand=True """ - expand = _neigh_expand_warning("network.ip_neighs6", expand) + expand = salt.utils.network.neigh_expand_warning("network.ip_neighs6", expand) entries = _parse_ip_neigh(":") if expand: return entries - return _neighs_flatten(entries) + return salt.utils.network.neighs_flatten(entries) ipneighs6 = salt.utils.functools.alias_function(ip_neighs6, "ipneighs6") diff --git a/salt/modules/win_network.py b/salt/modules/win_network.py index e2a099d0512a..759fbe4321e7 100644 --- a/salt/modules/win_network.py +++ b/salt/modules/win_network.py @@ -11,7 +11,6 @@ import salt.utils.network import salt.utils.platform import salt.utils.validate.net -import salt.utils.versions from salt._compat import ipaddress from salt.modules.network import ( calc_net, @@ -631,25 +630,6 @@ def is_private(ip_addr): return ipaddress.ip_address(ip_addr).is_private -def _neigh_expand_warning(func_name, expand): - """ - Warn about the upcoming neighbour table return shape change when the - caller did not pass ``expand`` explicitly, and return the effective - value of ``expand``. - """ - if expand is None: - salt.utils.versions.warn_until( - 3011, - f"In salt 3011, {func_name} will return a list of neighbour entry " - "dicts by default instead of a mac-to-ip mapping, which silently " - "drops entries whenever several IP addresses share a MAC address. " - "Pass expand=True to opt in to the new shape now, or expand=False " - "to keep the current shape and silence this warning.", - ) - return False - return expand - - def _get_neighbors(address_family): """ Return the neighbour (ARP/NDP) table for the given address family @@ -701,15 +681,6 @@ def _get_neighbors(address_family): return entries -def _neighs_flatten(entries): - """ - Flatten neighbour entry dicts into the legacy ``{mac: ip}`` shape used - by the Unix network module. When several entries share a MAC address, - the last one wins. - """ - return {entry["mac"]: entry["ip"] for entry in entries} - - def arp(expand=None): """ Return the arp table from the minion @@ -734,11 +705,11 @@ def arp(expand=None): salt '*' network.arp salt '*' network.arp expand=True """ - expand = _neigh_expand_warning("network.arp", expand) + expand = salt.utils.network.neigh_expand_warning("network.arp", expand) entries = _get_neighbors("IPv4") if expand: return entries - return _neighs_flatten(entries) + return salt.utils.network.neighs_flatten(entries) def ip_neighs(expand=None): @@ -765,11 +736,11 @@ def ip_neighs(expand=None): salt '*' network.ip_neighs salt '*' network.ip_neighs expand=True """ - expand = _neigh_expand_warning("network.ip_neighs", expand) + expand = salt.utils.network.neigh_expand_warning("network.ip_neighs", expand) entries = _get_neighbors("IPv4") if expand: return entries - return _neighs_flatten(entries) + return salt.utils.network.neighs_flatten(entries) ipneighs = salt.utils.functools.alias_function(ip_neighs, "ipneighs") @@ -801,11 +772,11 @@ def ip_neighs6(expand=None): salt '*' network.ip_neighs6 salt '*' network.ip_neighs6 expand=True """ - expand = _neigh_expand_warning("network.ip_neighs6", expand) + expand = salt.utils.network.neigh_expand_warning("network.ip_neighs6", expand) entries = _get_neighbors("IPv6") if expand: return entries - return _neighs_flatten(entries) + return salt.utils.network.neighs_flatten(entries) ipneighs6 = salt.utils.functools.alias_function(ip_neighs6, "ipneighs6") diff --git a/salt/utils/network.py b/salt/utils/network.py index fa63a8058860..bd5df8577345 100644 --- a/salt/utils/network.py +++ b/salt/utils/network.py @@ -26,7 +26,7 @@ from salt._compat import ipaddress from salt.exceptions import SaltClientError, SaltSystemExit from salt.utils.decorators.jinja import jinja_filter -from salt.utils.versions import Version +from salt.utils.versions import Version, warn_until try: import salt.utils.win_network @@ -2379,3 +2379,31 @@ def ip_bracket(addr, strip=False): addr = addr.rstrip("]") addr = ipaddress.ip_address(addr) return ("[{}]" if addr.version == 6 and not strip else "{}").format(addr) + + +def neigh_expand_warning(func_name, expand): + """ + Warn about the upcoming neighbour table return shape change when the + caller did not pass ``expand`` explicitly, and return the effective + value of ``expand``. + """ + if expand is None: + warn_until( + 3011, + f"In salt 3011, {func_name} will return a list of neighbour entry " + "dicts by default instead of a mac-to-ip mapping, which silently " + "drops entries whenever several IP addresses share a MAC address. " + "Pass expand=True to opt in to the new shape now, or expand=False " + "to keep the current shape and silence this warning.", + ) + return False + return expand + + +def neighs_flatten(entries): + """ + Flatten neighbour entry dicts into the legacy ``{mac: ip}`` shape. When + several entries share a MAC address, the last one wins, matching the + historical behaviour. + """ + return {entry["mac"]: entry["ip"] for entry in entries} diff --git a/tests/pytests/unit/utils/test_network.py b/tests/pytests/unit/utils/test_network.py index b44ea8ec576b..ec988170245a 100644 --- a/tests/pytests/unit/utils/test_network.py +++ b/tests/pytests/unit/utils/test_network.py @@ -1,6 +1,7 @@ import logging import socket import textwrap +import warnings import pytest @@ -1680,3 +1681,51 @@ def test_is_reachable_host_resolvable_returns_true(): MagicMock(return_value=[(socket.AF_INET, 0, 0, "", ("127.0.0.1", 0))]), ): assert network.is_reachable_host("localhost") is True + + +def test_neighs_flatten_maps_mac_to_ip(): + """ + neighs_flatten collapses neighbour entry dicts into the legacy + ``{mac: ip}`` mapping. + """ + entries = [ + {"ip": "203.0.113.1", "mac": "00:00:5e:00:53:01", "dev": "eth0", "state": None}, + {"ip": "203.0.113.2", "mac": "00:00:5e:00:53:02", "dev": "eth0", "state": None}, + ] + assert network.neighs_flatten(entries) == { + "00:00:5e:00:53:01": "203.0.113.1", + "00:00:5e:00:53:02": "203.0.113.2", + } + + +def test_neighs_flatten_last_wins_on_duplicate_mac(): + """ + When several entries share a MAC address the last one parsed wins, + matching the historical lossy behaviour of the flat mapping. + """ + entries = [ + {"ip": "203.0.113.1", "mac": "00:00:5e:00:53:01", "dev": "eth0", "state": None}, + {"ip": "203.0.113.9", "mac": "00:00:5e:00:53:01", "dev": "eth0", "state": None}, + ] + assert network.neighs_flatten(entries) == {"00:00:5e:00:53:01": "203.0.113.9"} + + +def test_neigh_expand_warning_returns_expand_when_set(): + """ + neigh_expand_warning returns an explicit expand value unchanged and does + not emit a deprecation warning. + """ + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert network.neigh_expand_warning("network.arp", True) is True + assert network.neigh_expand_warning("network.arp", False) is False + + +def test_neigh_expand_warning_none_warns_and_returns_false(): + """ + When expand is not supplied, neigh_expand_warning emits the deprecation + warning and falls back to the legacy (False) shape. + """ + with pytest.warns(DeprecationWarning, match="network.arp"): + result = network.neigh_expand_warning("network.arp", None) + assert result is False From 4669c2b18e537384be909d35d66be3112eef171b Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 12 Aug 2026 06:25:27 -0400 Subject: [PATCH 6/6] win_network: use in-process PowerShellSession for the neighbour query Address review feedback: _get_neighbors ran Get-NetNeighbor through cmd.powershell, which spins up a powershell.exe subprocess per call. Switch to the in-process salt.utils.win_pwsh.PowerShellSession runspace (via run_json), matching win_ip. win_network is a mixed module and cannot gate __virtual__ on the PowerShell SDK the way win_ip does, so guard the call with HAS_PWSH_SDK and raise a clear CommandExecutionError (instead of a NameError from instantiating PowerShellSession) on the rare install without pythonnet; the standard Salt onedir bundles it. Update the unit tests to mock the session path and add a guard test. --- salt/modules/win_network.py | 20 ++++++- .../pytests/unit/modules/test_win_network.py | 52 +++++++++++++------ 2 files changed, 55 insertions(+), 17 deletions(-) diff --git a/salt/modules/win_network.py b/salt/modules/win_network.py index 759fbe4321e7..89e555632b39 100644 --- a/salt/modules/win_network.py +++ b/salt/modules/win_network.py @@ -11,7 +11,9 @@ import salt.utils.network import salt.utils.platform import salt.utils.validate.net +import salt.utils.win_pwsh from salt._compat import ipaddress +from salt.exceptions import CommandExecutionError from salt.modules.network import ( calc_net, convert_cidr, @@ -647,8 +649,22 @@ def _get_neighbors(address_family): "Select-Object IPAddress, LinkLayerAddress, InterfaceAlias, " "@{Name='State'; Expression={$_.State.ToString()}}" ) - results = __salt__["cmd.powershell"](cmd) - if isinstance(results, dict): + # Use the in-process PowerShell runspace, which avoids spinning up a + # powershell.exe subprocess per call. Its runspace requires the PowerShell + # SDK (pythonnet), which the standard Salt onedir installer bundles; + # PowerShellSession must not be instantiated without it. + if not salt.utils.win_pwsh.HAS_PWSH_SDK: + raise CommandExecutionError( + "The Windows neighbour table requires the in-process PowerShell " + "SDK (pythonnet), which the standard Salt installer includes." + ) + with salt.utils.win_pwsh.PowerShellSession() as session: + results = session.run_json(cmd) + + if not results: + # No neighbours in this address family: run_json returns None + results = [] + elif isinstance(results, dict): # A single neighbour serializes to a bare object rather than a list results = [results] diff --git a/tests/pytests/unit/modules/test_win_network.py b/tests/pytests/unit/modules/test_win_network.py index ba372fd2e2a5..10331092462e 100644 --- a/tests/pytests/unit/modules/test_win_network.py +++ b/tests/pytests/unit/modules/test_win_network.py @@ -2,6 +2,7 @@ :codeauthor: Jayesh Kariya """ +import contextlib import socket import warnings @@ -9,6 +10,7 @@ import salt.modules.win_network as win_network import salt.utils.network +from salt.exceptions import CommandExecutionError from tests.support.mock import MagicMock, Mock, patch try: @@ -298,6 +300,22 @@ def test_connect_53371(): ) +@contextlib.contextmanager +def _patch_neighbor_query(return_value): + """ + Patch the in-process PowerShell path ``_get_neighbors`` uses and yield the + mock whose first positional argument is the executed command string. The + standard Salt onedir bundles pythonnet, so this mocks + ``PowerShellSession.run_json``. + """ + with patch("salt.utils.win_pwsh.HAS_PWSH_SDK", True), patch( + "salt.utils.win_pwsh.PowerShellSession" + ) as mock_session: + run_json = mock_session.return_value.__enter__.return_value.run_json + run_json.return_value = return_value + yield run_json + + def test_arp_expand(): """ arp(expand=True) maps Get-NetNeighbor objects to entry dicts, skipping @@ -347,8 +365,7 @@ def test_arp_expand(): "State": "Permanent", }, ] - mock_powershell = MagicMock(return_value=neighbors) - with patch.dict(win_network.__salt__, {"cmd.powershell": mock_powershell}): + with _patch_neighbor_query(neighbors) as mock_cmd: assert win_network.arp(expand=True) == [ { "ip": "203.0.113.1", @@ -363,7 +380,7 @@ def test_arp_expand(): "state": "STALE", }, ] - assert "-AddressFamily IPv4" in mock_powershell.call_args[0][0] + assert "-AddressFamily IPv4" in mock_cmd.call_args[0][0] def test_arp_default_warns_and_collapses(): @@ -386,9 +403,7 @@ def test_arp_default_warns_and_collapses(): "State": "Stale", }, ] - with patch.dict( - win_network.__salt__, {"cmd.powershell": MagicMock(return_value=neighbors)} - ): + with _patch_neighbor_query(neighbors): with pytest.warns(DeprecationWarning, match="network.arp"): result = win_network.arp() assert result == {"00:00:5e:00:53:01": "203.0.113.9"} @@ -405,9 +420,7 @@ def test_ip_neighs_single_neighbor(): "InterfaceAlias": "Ethernet0", "State": "Reachable", } - with patch.dict( - win_network.__salt__, {"cmd.powershell": MagicMock(return_value=neighbor)} - ): + with _patch_neighbor_query(neighbor): assert win_network.ip_neighs(expand=False) == { "00:00:5e:00:53:01": "203.0.113.1" } @@ -439,8 +452,7 @@ def test_ip_neighs6_expand(): "State": "Permanent", }, ] - mock_powershell = MagicMock(return_value=neighbors) - with patch.dict(win_network.__salt__, {"cmd.powershell": mock_powershell}): + with _patch_neighbor_query(neighbors) as mock_cmd: expanded = win_network.ip_neighs6(expand=True) assert expanded == [ { @@ -458,7 +470,7 @@ def test_ip_neighs6_expand(): ] # The legacy shape drops one of the two addresses. assert len(win_network.ip_neighs6(expand=False)) == 1 - assert "-AddressFamily IPv6" in mock_powershell.call_args[0][0] + assert "-AddressFamily IPv6" in mock_cmd.call_args[0][0] def test_ip_neighs_expand_false_does_not_warn(): @@ -472,10 +484,20 @@ def test_ip_neighs_expand_false_does_not_warn(): "InterfaceAlias": "Ethernet0", "State": "Reachable", } - with patch.dict( - win_network.__salt__, {"cmd.powershell": MagicMock(return_value=neighbor)} - ): + with _patch_neighbor_query(neighbor): with warnings.catch_warnings(): warnings.simplefilter("error") result = win_network.ip_neighs(expand=False) assert result == {"00:00:5e:00:53:01": "203.0.113.1"} + + +def test_get_neighbors_requires_pwsh_sdk(): + """ + Without the in-process PowerShell SDK (pythonnet) -- e.g. a pip install of + Salt on Windows rather than the bundled onedir -- the neighbour lookup + raises a clear error rather than a NameError from instantiating + PowerShellSession. + """ + with patch("salt.utils.win_pwsh.HAS_PWSH_SDK", False): + with pytest.raises(CommandExecutionError, match="PowerShell SDK"): + win_network.arp(expand=True)