mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
Each enabled virtual printer needs its own IP address, and the documented way to get several is to add secondary addresses to the adapter already in use. Linux reads those back through `ip -j addr show`, which reports every address. Windows and macOS have no `ip` command and fell through to a psutil enumeration that stopped at the first IPv4 of each adapter, so a host with three addresses on one NIC offered exactly one bind target and the second virtual printer could only fail with "Bind IP ... is already in use". The psutil path now collects every address, marking the ones after an adapter's first as aliases the way the iproute2 path does. Callers that want interfaces rather than addresses -- the discovery scan and the support bundle -- project the primaries back out, so their view is unchanged. The ioctl fallback that a Linux host without iproute2 used to get is now reached only when psutil itself is missing, which gains that host aliases too. The interface-name exclusions stay Linux-only: they are Linux device names, and a Windows adapter called "Local Area Connection" matches the "lo" prefix.
290 lines
13 KiB
Python
290 lines
13 KiB
Python
"""Tests for network interface enumeration.
|
|
|
|
Focus: the platform routing in get_network_interfaces(). macOS/BSD have fcntl
|
|
but not the Linux SIOCGIFADDR/SIOCGIFNETMASK ioctls, so the ioctl path there
|
|
silently returns nothing and the VP bind-interface dropdown comes up empty.
|
|
Everything that isn't Linux must go through the cross-platform psutil path.
|
|
"""
|
|
|
|
import socket
|
|
from collections import namedtuple
|
|
from unittest.mock import patch
|
|
|
|
from backend.app.services import network_utils
|
|
|
|
# Mimic the shape of psutil.net_if_addrs() / net_if_stats() entries we read.
|
|
_Addr = namedtuple("snicaddr", ["family", "address", "netmask", "broadcast", "ptp"])
|
|
_Stats = namedtuple("snicstats", ["isup", "duplex", "speed", "mtu", "flags"])
|
|
|
|
|
|
def _fake_psutil():
|
|
addrs = {
|
|
"en0": [_Addr(socket.AF_INET, "192.168.1.50", "255.255.255.0", None, None)],
|
|
"lo0": [_Addr(socket.AF_INET, "127.0.0.1", "255.0.0.0", None, None)],
|
|
"awdl0": [_Addr(socket.AF_INET, "169.254.10.20", "255.255.0.0", None, None)],
|
|
"utun3": [_Addr(socket.AF_INET, "100.64.0.7", "255.255.255.255", None, None)],
|
|
"en5": [_Addr(socket.AF_INET, "10.0.0.9", "255.255.255.0", None, None)],
|
|
}
|
|
stats = {
|
|
"en0": _Stats(True, 0, 0, 1500, 0),
|
|
"lo0": _Stats(True, 0, 0, 16384, 0),
|
|
"awdl0": _Stats(True, 0, 0, 1500, 0),
|
|
"utun3": _Stats(True, 0, 0, 1500, 0),
|
|
"en5": _Stats(False, 0, 0, 1500, 0), # down → skipped
|
|
}
|
|
return addrs, stats
|
|
|
|
|
|
@patch("backend.app.services.network_utils.sys")
|
|
def test_macos_routes_to_psutil(mock_sys):
|
|
"""On darwin, get_network_interfaces() must use psutil, not the ioctl path."""
|
|
mock_sys.platform = "darwin"
|
|
with patch.object(network_utils, "_get_network_interfaces_psutil", return_value=[{"name": "en0"}]) as psutil_path:
|
|
result = network_utils.get_network_interfaces()
|
|
psutil_path.assert_called_once()
|
|
assert result == [{"name": "en0"}]
|
|
|
|
|
|
@patch("backend.app.services.network_utils.sys")
|
|
def test_windows_routes_to_psutil(mock_sys):
|
|
mock_sys.platform = "win32"
|
|
with patch.object(network_utils, "_get_network_interfaces_psutil", return_value=[]) as psutil_path:
|
|
network_utils.get_network_interfaces()
|
|
psutil_path.assert_called_once()
|
|
|
|
|
|
@patch("backend.app.services.network_utils.sys")
|
|
def test_linux_does_not_use_psutil(mock_sys):
|
|
"""Linux keeps the ioctl path — psutil helper must not be invoked."""
|
|
mock_sys.platform = "linux"
|
|
with patch.object(network_utils, "_get_network_interfaces_psutil") as psutil_path:
|
|
# The ioctl path runs for real here; we only assert it wasn't short-circuited
|
|
# to psutil. Its actual return depends on the host, so we don't assert on it.
|
|
network_utils.get_network_interfaces()
|
|
psutil_path.assert_not_called()
|
|
|
|
|
|
def test_psutil_path_filters_and_returns_bindable_ips():
|
|
"""The psutil path drops loopback/link-local/down ifaces, keeps real + VPN ones."""
|
|
addrs, stats = _fake_psutil()
|
|
with (
|
|
patch("psutil.net_if_addrs", return_value=addrs),
|
|
patch("psutil.net_if_stats", return_value=stats),
|
|
):
|
|
result = network_utils._get_network_interfaces_psutil()
|
|
|
|
by_name = {i["name"]: i for i in result}
|
|
assert "en0" in by_name # normal LAN interface
|
|
assert by_name["en0"]["ip"] == "192.168.1.50"
|
|
assert by_name["en0"]["subnet"] == "192.168.1.0/24"
|
|
assert "utun3" in by_name # Tailscale/VPN — legitimately bindable
|
|
assert "lo0" not in by_name # loopback filtered
|
|
assert "awdl0" not in by_name # link-local (169.254) filtered
|
|
assert "en5" not in by_name # interface down, skipped
|
|
|
|
|
|
_IP_ADDR_JSON = """[
|
|
{"ifname": "lo", "addr_info": [{"family": "inet", "local": "127.0.0.1", "prefixlen": 8}]},
|
|
{"ifname": "enp3s0", "addr_info": [{"family": "inet", "local": "192.168.96.9", "prefixlen": 22}]},
|
|
{"ifname": "enp4s0", "addr_info": [
|
|
{"family": "inet", "local": "10.0.0.5", "prefixlen": 24},
|
|
{"family": "inet", "local": "10.0.0.6", "prefixlen": 24, "label": "enp4s0:vp1"}
|
|
]},
|
|
{"ifname": "docker0", "addr_info": [{"family": "inet", "local": "172.17.0.1", "prefixlen": 16}]}
|
|
]"""
|
|
|
|
|
|
def _fake_ip_addr():
|
|
"""Patch `ip -j addr show` with a fixed multi-homed Linux host."""
|
|
result = namedtuple("CompletedProcess", ["returncode", "stdout", "stderr"])(0, _IP_ADDR_JSON, "")
|
|
return patch.object(network_utils, "subprocess", **{"run.return_value": result})
|
|
|
|
|
|
class TestFindLocalIPv4Network:
|
|
"""#3092: an address carries no prefix, so it has to be read off the interface."""
|
|
|
|
def test_reads_the_configured_prefix_not_a_guessed_24(self):
|
|
with _fake_ip_addr(), patch.object(network_utils, "_IP_CMD", "/usr/sbin/ip"):
|
|
assert str(network_utils.find_local_ipv4_network("192.168.96.9")) == "192.168.96.0/22"
|
|
|
|
def test_an_alias_address_resolves_too(self):
|
|
# The VP binds aliases; an alias is a perfectly good route source.
|
|
with _fake_ip_addr(), patch.object(network_utils, "_IP_CMD", "/usr/sbin/ip"):
|
|
assert str(network_utils.find_local_ipv4_network("10.0.0.6")) == "10.0.0.0/24"
|
|
|
|
def test_an_excluded_interface_still_answers(self):
|
|
"""EXCLUDED_INTERFACE_PREFIXES keeps docker0 out of the VP dropdown.
|
|
|
|
It must not also make the kernel's own choice of route source
|
|
unanswerable — "unknown" would be a worse answer than the truth.
|
|
"""
|
|
with _fake_ip_addr(), patch.object(network_utils, "_IP_CMD", "/usr/sbin/ip"):
|
|
assert str(network_utils.find_local_ipv4_network("172.17.0.1")) == "172.17.0.0/16"
|
|
assert not [i for i in network_utils.get_all_interface_ips() if i["name"] == "docker0"]
|
|
|
|
def test_an_address_no_interface_holds_is_none(self):
|
|
with _fake_ip_addr(), patch.object(network_utils, "_IP_CMD", "/usr/sbin/ip"):
|
|
assert network_utils.find_local_ipv4_network("192.168.1.1") is None
|
|
|
|
def test_a_hostname_is_none(self):
|
|
assert network_utils.find_local_ipv4_network("printer.local") is None
|
|
|
|
|
|
def _fake_windows_psutil():
|
|
"""#3121's host: one vmxnet3 NIC carrying three IPv4 addresses.
|
|
|
|
"Local Area Connection" is here on purpose — it starts with ``lo``, so it
|
|
is what EXCLUDED_INTERFACE_PREFIXES would eat if the Linux name filter were
|
|
applied to Windows adapter names.
|
|
"""
|
|
addrs = {
|
|
"Ethernet0": [
|
|
_Addr(socket.AF_INET, "10.10.24.6", "255.255.255.0", None, None),
|
|
_Addr(socket.AF_INET, "10.10.24.7", "255.255.255.0", None, None),
|
|
_Addr(socket.AF_INET, "10.10.24.8", "255.255.255.0", None, None),
|
|
],
|
|
"Local Area Connection": [_Addr(socket.AF_INET, "192.168.7.5", "255.255.255.0", None, None)],
|
|
}
|
|
stats = {
|
|
"Ethernet0": _Stats(True, 0, 0, 1500, 0),
|
|
"Local Area Connection": _Stats(True, 0, 0, 1500, 0),
|
|
}
|
|
return addrs, stats
|
|
|
|
|
|
def _patch_psutil(addrs, stats):
|
|
"""Both psutil calls the enumerator makes, as one context manager."""
|
|
return patch.multiple(
|
|
"psutil",
|
|
net_if_addrs=lambda: addrs,
|
|
net_if_stats=lambda: stats,
|
|
)
|
|
|
|
|
|
class TestSecondaryAddresses:
|
|
"""#3121: a NIC with several IPv4 addresses is several VP bind targets.
|
|
|
|
The Virtual Printer needs one bind IP per printer. Linux gets one dropdown
|
|
entry per alias from `ip -j addr show`; Windows and macOS have no `ip`, so
|
|
everything they offer comes out of psutil.
|
|
"""
|
|
|
|
def test_every_ipv4_on_an_interface_is_listed(self):
|
|
addrs, stats = _fake_windows_psutil()
|
|
with _patch_psutil(addrs, stats):
|
|
entries = network_utils._psutil_ipv4_entries()
|
|
|
|
eth0 = [e for e in entries if e["name"] == "Ethernet0"]
|
|
assert [e["ip"] for e in eth0] == ["10.10.24.6", "10.10.24.7", "10.10.24.8"]
|
|
# Position is the only alias signal on this path: first = primary.
|
|
assert [e["is_alias"] for e in eth0] == [False, True, True]
|
|
assert {e["subnet"] for e in eth0} == {"10.10.24.0/24"}
|
|
|
|
def test_get_network_interfaces_still_returns_one_per_interface(self):
|
|
"""Discovery subnets and the support bundle want interfaces, not aliases."""
|
|
addrs, stats = _fake_windows_psutil()
|
|
with _patch_psutil(addrs, stats):
|
|
result = network_utils._get_network_interfaces_psutil()
|
|
|
|
assert [i["ip"] for i in result if i["name"] == "Ethernet0"] == ["10.10.24.6"]
|
|
# The narrower shape this function has always returned.
|
|
assert set(result[0]) == {"name", "ip", "netmask", "subnet"}
|
|
|
|
@patch("backend.app.services.network_utils.sys")
|
|
def test_windows_dropdown_offers_each_secondary_ip(self, mock_sys):
|
|
"""The actual bug: only one entry per NIC reached the bind dropdown."""
|
|
mock_sys.platform = "win32"
|
|
addrs, stats = _fake_windows_psutil()
|
|
with _patch_psutil(addrs, stats):
|
|
entries = network_utils.get_all_interface_ips()
|
|
|
|
assert [e["ip"] for e in entries if e["name"] == "Ethernet0"] == [
|
|
"10.10.24.6",
|
|
"10.10.24.7",
|
|
"10.10.24.8",
|
|
]
|
|
|
|
@patch("backend.app.services.network_utils.sys")
|
|
def test_windows_keeps_adapters_matching_a_linux_prefix(self, mock_sys):
|
|
"""EXCLUDED_INTERFACE_PREFIXES must not run against Windows names."""
|
|
mock_sys.platform = "win32"
|
|
addrs, stats = _fake_windows_psutil()
|
|
with _patch_psutil(addrs, stats):
|
|
entries = network_utils.get_all_interface_ips()
|
|
|
|
assert "Local Area Connection" in {e["name"] for e in entries}
|
|
|
|
@patch("backend.app.services.network_utils.sys")
|
|
def test_linux_without_iproute2_gets_aliases_and_keeps_its_exclusions(self, mock_sys):
|
|
"""psutil replaces the ioctl fallback, so no-`ip` hosts see aliases too.
|
|
|
|
The name exclusions still apply here — unlike Windows, these really are
|
|
the local device names, and docker0 has no business in the dropdown.
|
|
"""
|
|
mock_sys.platform = "linux"
|
|
addrs = {
|
|
"eth0": [
|
|
_Addr(socket.AF_INET, "192.168.1.100", "255.255.255.0", None, None),
|
|
_Addr(socket.AF_INET, "192.168.1.101", "255.255.255.0", None, None),
|
|
],
|
|
"docker0": [_Addr(socket.AF_INET, "172.17.0.1", "255.255.0.0", None, None)],
|
|
}
|
|
stats = {"eth0": _Stats(True, 0, 0, 1500, 0), "docker0": _Stats(True, 0, 0, 1500, 0)}
|
|
|
|
with _patch_psutil(addrs, stats), patch.object(network_utils, "_IP_CMD", None):
|
|
entries = network_utils.get_all_interface_ips()
|
|
unfiltered = network_utils.get_all_interface_ips(include_excluded=True)
|
|
|
|
assert [e["ip"] for e in entries] == ["192.168.1.100", "192.168.1.101"]
|
|
assert "docker0" not in {e["name"] for e in entries}
|
|
assert "docker0" in {e["name"] for e in unfiltered}
|
|
|
|
@patch("backend.app.services.network_utils.sys")
|
|
def test_ioctl_remains_the_last_resort(self, mock_sys):
|
|
"""A venv without psutil still enumerates, just without the aliases."""
|
|
mock_sys.platform = "linux"
|
|
with (
|
|
patch.object(network_utils, "_IP_CMD", None),
|
|
patch.object(network_utils, "_psutil_ipv4_entries", return_value=[]),
|
|
patch.object(
|
|
network_utils,
|
|
"get_network_interfaces",
|
|
return_value=[
|
|
{"name": "eth0", "ip": "192.168.1.100", "netmask": "255.255.255.0", "subnet": "192.168.1.0/24"}
|
|
],
|
|
),
|
|
):
|
|
entries = network_utils.get_all_interface_ips()
|
|
|
|
assert entries == [
|
|
{
|
|
"name": "eth0",
|
|
"ip": "192.168.1.100",
|
|
"netmask": "255.255.255.0",
|
|
"subnet": "192.168.1.0/24",
|
|
"is_alias": False,
|
|
"label": "eth0",
|
|
}
|
|
]
|
|
|
|
@patch("backend.app.services.network_utils.sys")
|
|
def test_adapter_order_is_preserved_for_source_ip_selection(self, mock_sys):
|
|
"""find_interface_for_ip() answers with the first match, so order matters.
|
|
|
|
The MQTT bridge takes that answer as the source IP for the #1429
|
|
rewrite and the SSDP proxy as its local interface. On a host with two
|
|
adapters on one subnet, re-ordering the enumeration would silently
|
|
re-pick both, so this path stays in psutil's adapter order rather than
|
|
being sorted by name the way the iproute2 path is.
|
|
"""
|
|
mock_sys.platform = "win32"
|
|
addrs = {
|
|
"Zeta": [_Addr(socket.AF_INET, "10.10.24.6", "255.255.255.0", None, None)],
|
|
"Alpha": [_Addr(socket.AF_INET, "10.10.24.9", "255.255.255.0", None, None)],
|
|
}
|
|
stats = {"Zeta": _Stats(True, 0, 0, 1500, 0), "Alpha": _Stats(True, 0, 0, 1500, 0)}
|
|
|
|
with _patch_psutil(addrs, stats):
|
|
assert [e["name"] for e in network_utils.get_all_interface_ips()] == ["Zeta", "Alpha"]
|
|
assert network_utils.find_interface_for_ip("10.10.24.200")["name"] == "Zeta"
|