diff --git a/changelog/69655.added.md b/changelog/69655.added.md new file mode 100644 index 00000000000..d5162d03209 --- /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.deprecated.md b/changelog/69655.deprecated.md new file mode 100644 index 00000000000..b02757a54cc --- /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 new file mode 100644 index 00000000000..2ce2ecbd8ca --- /dev/null +++ b/changelog/69655.fixed.md @@ -0,0 +1 @@ +``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 8fd4d9bbffb..e97aab8d8fc 100644 --- a/salt/modules/network.py +++ b/salt/modules/network.py @@ -1088,20 +1088,37 @@ def dig(host): @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:: 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 + ``DeprecationWarning``. Unresolved (incomplete) entries are no + longer included in either shape. + CLI Example: .. code-block:: bash salt '*' network.arp + salt '*' network.arp expand=True """ - ret = {} + expand = salt.utils.network.neigh_expand_warning("network.arp", expand) + entries = [] out = __salt__["cmd.run"]("arp -an") for line in out.splitlines(): comps = line.split() @@ -1110,19 +1127,53 @@ 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(")") + if comps[3] in ("", "(incomplete)"): + continue + entries.append( + { + "ip": comps[1].strip("(").strip(")"), + "mac": comps[3], + "dev": None, + "state": None, + } + ) else: - ret[comps[3]] = comps[1].strip("(").strip(")") + if comps[3] in ("", "(incomplete)"): + continue + 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 salt.utils.network.neighs_flatten(entries) def interfaces(): @@ -1343,6 +1394,114 @@ 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() + # 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 + 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:: 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 + ``DeprecationWarning``. Unresolved entries (those without a + link-layer address) are no longer included in either shape. + + CLI Example: + + .. code-block:: bash + + salt '*' network.ip_neighs + salt '*' network.ip_neighs expand=True + """ + expand = salt.utils.network.neigh_expand_warning("network.ip_neighs", expand) + entries = _parse_ip_neigh(".") + if expand: + return entries + return salt.utils.network.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:: 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 + ``DeprecationWarning``. Unresolved entries (those without a + link-layer address) are no longer included in either shape. + + CLI Example: + + .. code-block:: bash + + salt '*' network.ip_neighs6 + salt '*' network.ip_neighs6 expand=True + """ + expand = salt.utils.network.neigh_expand_warning("network.ip_neighs6", expand) + entries = _parse_ip_neigh(":") + if expand: + return entries + return salt.utils.network.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 daaba746b1d..89e555632b3 100644 --- a/salt/modules/win_network.py +++ b/salt/modules/win_network.py @@ -7,10 +7,13 @@ import re import socket +import salt.utils.functools 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, @@ -627,3 +630,169 @@ def is_private(ip_addr): salt '*' network.is_private 10.0.0.3 """ return ipaddress.ip_address(ip_addr).is_private + + +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. + + 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} | " + "Select-Object IPAddress, LinkLayerAddress, InterfaceAlias, " + "@{Name='State'; Expression={$_.State.ToString()}}" + ) + # 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] + + entries = [] + for neighbor in results: + mac = neighbor.get("LinkLayerAddress") + if not mac: + # Unreachable/incomplete entries carry no link-layer address + continue + 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": neighbor.get("IPAddress"), + "mac": mac, + "dev": neighbor.get("InterfaceAlias"), + "state": state.upper() if state else state, + } + ) + + return 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:: 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 + deprecation cycle of the Unix network module. + + CLI Example: + + .. code-block:: bash + + salt '*' network.arp + salt '*' network.arp expand=True + """ + expand = salt.utils.network.neigh_expand_warning("network.arp", expand) + entries = _get_neighbors("IPv4") + if expand: + return entries + return salt.utils.network.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:: 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 + deprecation cycle of the Unix network module. + + CLI Example: + + .. code-block:: bash + + salt '*' network.ip_neighs + salt '*' network.ip_neighs expand=True + """ + expand = salt.utils.network.neigh_expand_warning("network.ip_neighs", expand) + entries = _get_neighbors("IPv4") + if expand: + return entries + return salt.utils.network.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:: 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 + deprecation cycle of the Unix network module. + + CLI Example: + + .. code-block:: bash + + salt '*' network.ip_neighs6 + salt '*' network.ip_neighs6 expand=True + """ + expand = salt.utils.network.neigh_expand_warning("network.ip_neighs6", expand) + entries = _get_neighbors("IPv6") + if expand: + return 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 a44558b4118..598263530d0 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 @@ -2381,6 +2381,34 @@ def ip_bracket(addr, strip=False): 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} + + def nm_managed(): """ Return ``True`` when this host is managed by NetworkManager without the diff --git a/tests/pytests/unit/modules/test_network.py b/tests/pytests/unit/modules/test_network.py index 81035434b61..033db161b33 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,1011 @@ 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" + "? (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)} + ), 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_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 + 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. + 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( + 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. 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( + 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 524bc198e2f..10331092462 100644 --- a/tests/pytests/unit/modules/test_win_network.py +++ b/tests/pytests/unit/modules/test_win_network.py @@ -2,12 +2,15 @@ :codeauthor: Jayesh Kariya """ +import contextlib import socket +import warnings import pytest 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: @@ -295,3 +298,206 @@ def test_connect_53371(): rtn["comment"] == "Unable to connect to test-server (unknown) on tcp port 80" ) + + +@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 + 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 = [ + { + "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", + }, + { + "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", + }, + { + # 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", + }, + ] + with _patch_neighbor_query(neighbors) as mock_cmd: + 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_cmd.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_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"} + + +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_neighbor_query(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", + }, + { + "IPAddress": "ff02::16", + "LinkLayerAddress": "33-33-00-00-00-16", + "InterfaceAlias": "Ethernet0", + "State": "Permanent", + }, + ] + with _patch_neighbor_query(neighbors) as mock_cmd: + 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_cmd.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_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) diff --git a/tests/pytests/unit/utils/test_network.py b/tests/pytests/unit/utils/test_network.py index b44ea8ec576..ec988170245 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 diff --git a/tests/unit/modules/test_network.py b/tests/unit/modules/test_network.py index 34b06250fc6..7c8faaf4792 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): """