From 9721660cc3ccdbd9831ccf8c3d845e7d61b06d39 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 10 Jul 2026 13:56:07 +0800 Subject: [PATCH 1/2] Expose full interface address list and link state in cloud node API Each host interface surfaced by the cloud node now reports: - ip_addresses: every IPv4 and IPv6 address (previously only a single IPv4 was collected internally and then dropped before the response) - status / speed / mtu / flags: operational state and link attributes sourced from psutil.net_if_stats(), with flags normalized to a list The legacy ip_address / netmask / mac_address fields are preserved so existing callers (compute link detection, GNS3 VM, VMware, has_netmask) keep working. The new fields travel through the existing interfaces payload that the controller forwards verbatim, so no controller-side change is required and the PUT / ports_mapping flow is unaffected. --- gns3server/compute/builtin/nodes/cloud.py | 11 +- gns3server/schemas/compute/cloud_nodes.py | 23 +++++ gns3server/utils/interfaces.py | 39 +++++++- tests/compute/builtin/nodes/test_cloud.py | 12 +-- tests/conftest.py | 6 +- tests/utils/test_interfaces.py | 116 ++++++++++++++++++++++ 6 files changed, 196 insertions(+), 11 deletions(-) diff --git a/gns3server/compute/builtin/nodes/cloud.py b/gns3server/compute/builtin/nodes/cloud.py index 43d3bcc1f..50bf11084 100644 --- a/gns3server/compute/builtin/nodes/cloud.py +++ b/gns3server/compute/builtin/nodes/cloud.py @@ -83,7 +83,16 @@ class Cloud(BaseNode): network_interfaces = gns3server.utils.interfaces.interfaces() for interface in network_interfaces: host_interfaces.append( - {"name": interface["name"], "type": interface["type"], "special": interface["special"]} + { + "name": interface["name"], + "type": interface["type"], + "special": interface["special"], + "ip_addresses": interface.get("ip_addresses", []), + "status": interface.get("status", "down"), + "speed": interface.get("speed", 0), + "mtu": interface.get("mtu", 0), + "flags": interface.get("flags", []), + } ) return { diff --git a/gns3server/schemas/compute/cloud_nodes.py b/gns3server/schemas/compute/cloud_nodes.py index a4dbfae40..1f2877fc6 100644 --- a/gns3server/schemas/compute/cloud_nodes.py +++ b/gns3server/schemas/compute/cloud_nodes.py @@ -28,6 +28,22 @@ class HostInterfaceType(str, Enum): tap = "tap" +class IPAddressFamily(str, Enum): + + ipv4 = "ipv4" + ipv6 = "ipv6" + + +class HostInterfaceIPAddress(BaseModel): + """ + An IP address (with optional netmask) bound to a host interface. + """ + + family: IPAddressFamily = Field(..., description="Address family (ipv4 or ipv6)") + address: str = Field(..., description="IP address") + netmask: Optional[str] = Field(None, description="Network mask, if available") + + class HostInterface(BaseModel): """ Interface on this host. @@ -36,6 +52,13 @@ class HostInterface(BaseModel): name: str = Field(..., description="Interface name") type: HostInterfaceType = Field(..., description="Interface type") special: bool = Field(..., description="Whether the interface is non standard") + ip_addresses: List[HostInterfaceIPAddress] = Field( + default_factory=list, description="All IPv4 and IPv6 addresses on this interface" + ) + status: str = Field("down", description="Interface status (up or down)") + speed: int = Field(0, description="Interface speed in Mbit/s (0 if unknown)") + mtu: int = Field(0, description="Interface MTU") + flags: List[str] = Field(default_factory=list, description="Interface flags") class EthernetType(str, Enum): diff --git a/gns3server/utils/interfaces.py b/gns3server/utils/interfaces.py index afed9ad49..e7292bea5 100644 --- a/gns3server/utils/interfaces.py +++ b/gns3server/utils/interfaces.py @@ -198,6 +198,7 @@ def interfaces(): results = [] allowed_interfaces = Config.instance().settings.Server.allowed_interfaces net_if_addrs = psutil.net_if_addrs() + net_if_stats = psutil.net_if_stats() for interface in sorted(net_if_addrs.keys()): if allowed_interfaces and interface not in allowed_interfaces and not interface.startswith("gns3tap"): log.warning(f"Interface '{interface}' is not allowed to be used on this server") @@ -206,16 +207,47 @@ def interfaces(): mac_address = "" netmask = "" interface_type = "ethernet" + # Collect every IPv4 and IPv6 address on this interface. An interface may + # carry several addresses of each family (or none at all), so we keep a + # list in addition to the legacy single IPv4 value retained for backward + # compatibility with existing callers (compute link detection, GNS3 VM, + # VMware, has_netmask(), ...). + ip_addresses = [] for addr in net_if_addrs[interface]: - # get the first available IPv4 address only if addr.family == socket.AF_INET: + # legacy single-value behavior (keeps the last IPv4 seen) ip_address = addr.address netmask = addr.netmask + ip_addresses.append( + {"family": "ipv4", "address": addr.address, "netmask": addr.netmask or None} + ) + elif addr.family == socket.AF_INET6: + ip_addresses.append( + {"family": "ipv6", "address": addr.address, "netmask": addr.netmask or None} + ) if addr.family == psutil.AF_LINK: mac_address = addr.address if interface.startswith("tap"): # found no way to reliably detect a TAP interface interface_type = "tap" + # Operational state and link attributes (speed/mtu/flags) come from + # psutil.net_if_stats(). An interface present in net_if_addrs is normally + # also present here; fall back to neutral defaults when it is not. + stats = net_if_stats.get(interface) + if stats is not None: + status = "up" if stats.isup else "down" + speed = stats.speed + mtu = stats.mtu + # psutil returns flags either as a comma-separated string (>= 6.0) or + # as a list (older versions); normalize to a list for a stable shape. + flags = stats.flags + if isinstance(flags, str): + flags = [flag for flag in flags.split(",") if flag] + else: + status = "down" + speed = 0 + mtu = 0 + flags = [] results.append( { "id": interface, @@ -224,6 +256,11 @@ def interfaces(): "netmask": netmask, "mac_address": mac_address, "type": interface_type, + "ip_addresses": ip_addresses, + "status": status, + "speed": speed, + "mtu": mtu, + "flags": flags, } ) diff --git a/tests/compute/builtin/nodes/test_cloud.py b/tests/compute/builtin/nodes/test_cloud.py index 0f7d3401c..60dbc1541 100644 --- a/tests/compute/builtin/nodes/test_cloud.py +++ b/tests/compute/builtin/nodes/test_cloud.py @@ -72,9 +72,9 @@ async def test_json_with_ports(on_gns3vm, compute_project, manager): } ], "interfaces": [ - {'name': 'eth0', 'special': False, 'type': 'ethernet'}, - {'name': 'eth1', 'special': False, 'type': 'ethernet'}, - {'name': 'virbr0', 'special': True, 'type': 'ethernet'} + {'name': 'eth0', 'special': False, 'type': 'ethernet', 'ip_addresses': [], 'status': 'up', 'speed': 1000, 'mtu': 1500, 'flags': ['up', 'broadcast', 'running', 'multicast']}, + {'name': 'eth1', 'special': False, 'type': 'ethernet', 'ip_addresses': [], 'status': 'down', 'speed': 0, 'mtu': 1500, 'flags': ['broadcast']}, + {'name': 'virbr0', 'special': True, 'type': 'ethernet', 'ip_addresses': [], 'status': 'up', 'speed': 10000, 'mtu': 1500, 'flags': ['up', 'broadcast', 'running', 'multicast']} ] } @@ -111,9 +111,9 @@ async def test_json_without_ports(on_gns3vm, compute_project, manager): } ], "interfaces": [ - {'name': 'eth0', 'special': False, 'type': 'ethernet'}, - {'name': 'eth1', 'special': False, 'type': 'ethernet'}, - {'name': 'virbr0', 'special': True, 'type': 'ethernet'} + {'name': 'eth0', 'special': False, 'type': 'ethernet', 'ip_addresses': [], 'status': 'up', 'speed': 1000, 'mtu': 1500, 'flags': ['up', 'broadcast', 'running', 'multicast']}, + {'name': 'eth1', 'special': False, 'type': 'ethernet', 'ip_addresses': [], 'status': 'down', 'speed': 0, 'mtu': 1500, 'flags': ['broadcast']}, + {'name': 'virbr0', 'special': True, 'type': 'ethernet', 'ip_addresses': [], 'status': 'up', 'speed': 10000, 'mtu': 1500, 'flags': ['up', 'broadcast', 'running', 'multicast']} ] } diff --git a/tests/conftest.py b/tests/conftest.py index 95b9176c9..4c6ba0525 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -325,9 +325,9 @@ def on_gns3vm(linux_platform): """ with patch("gns3server.utils.interfaces.interfaces", return_value=[ - {"name": "eth0", "special": False, "type": "ethernet"}, - {"name": "eth1", "special": False, "type": "ethernet"}, - {"name": "virbr0", "special": True, "type": "ethernet"}]): + {"name": "eth0", "special": False, "type": "ethernet", "ip_addresses": [], "status": "up", "speed": 1000, "mtu": 1500, "flags": ["up", "broadcast", "running", "multicast"]}, + {"name": "eth1", "special": False, "type": "ethernet", "ip_addresses": [], "status": "down", "speed": 0, "mtu": 1500, "flags": ["broadcast"]}, + {"name": "virbr0", "special": True, "type": "ethernet", "ip_addresses": [], "status": "up", "speed": 10000, "mtu": 1500, "flags": ["up", "broadcast", "running", "multicast"]}]): with patch("socket.gethostname", return_value="gns3vm"): yield diff --git a/tests/utils/test_interfaces.py b/tests/utils/test_interfaces.py index 5d581fe31..3d912a09c 100644 --- a/tests/utils/test_interfaces.py +++ b/tests/utils/test_interfaces.py @@ -16,10 +16,21 @@ # along with this program. If not, see . import sys +import socket +import collections +from unittest.mock import patch + +import psutil from gns3server.utils.interfaces import interfaces, is_interface_up, has_netmask +# psutil returns snicaddr namedtuples; mirror that shape for the mocks below. +snicaddr = collections.namedtuple("snicaddr", ["family", "address", "netmask", "broadcast", "ptp"]) +# psutil.net_if_stats() returns snicstats namedtuples. +snicstats = collections.namedtuple("snicstats", ["isup", "duplex", "speed", "mtu", "flags"]) + + def test_interfaces(): # This test should pass on all platforms without crash @@ -35,6 +46,111 @@ def test_interfaces(): assert "mac_address" in interface assert "type" in interface assert "netmask" in interface + assert "ip_addresses" in interface + assert "status" in interface + assert "speed" in interface + assert "mtu" in interface + assert "flags" in interface + + +def _fake_net_if_addrs(): + # A representative set of host interfaces exercising the address-collection logic. + return { + # several IPv4 and several IPv6 addresses on the same interface + "eth0": [ + snicaddr(socket.AF_INET, "192.168.1.5", "255.255.255.0", "192.168.1.255", None), + snicaddr(socket.AF_INET, "10.0.0.5", "255.255.255.0", "10.0.0.255", None), + snicaddr(socket.AF_INET6, "fe80::1", "ffff:ffff:ffff:ffff::", None, None), + snicaddr(socket.AF_INET6, "2001:db8::1", "ffff:ffff:ffff:ffff::", None, None), + snicaddr(psutil.AF_LINK, "00:11:22:33:44:55", None, None, None), + ], + # no IP address at all (only a MAC) + "eth1": [ + snicaddr(psutil.AF_LINK, "00:11:22:33:44:66", None, None, None), + ], + # IPv6 only + "eth2": [ + snicaddr(socket.AF_INET6, "2001:db8::2", "ffff:ffff:ffff:ffff::", None, None), + snicaddr(psutil.AF_LINK, "00:11:22:33:44:77", None, None, None), + ], + # present in net_if_addrs but absent from net_if_stats (exercises the fallback) + "eth3": [ + snicaddr(psutil.AF_LINK, "00:11:22:33:44:88", None, None, None), + ], + } + + +def _fake_net_if_stats(): + # flags are returned as a comma-separated string by psutil >= 6.0 + return { + "eth0": snicstats(True, 0, 1000, 1500, "up,broadcast,running,multicast"), + "eth1": snicstats(False, 0, 0, 1500, "broadcast"), + "eth2": snicstats(True, 0, 0, 1500, "up,running"), + } + + +@patch("gns3server.utils.interfaces.psutil.net_if_stats", _fake_net_if_stats) +@patch("gns3server.utils.interfaces.psutil.net_if_addrs", _fake_net_if_addrs) +def test_interfaces_collects_all_ip_addresses(config): + + result = {iface["name"]: iface for iface in interfaces()} + eth0 = result["eth0"] + + # every IPv4 and IPv6 address is reported, preserving order + assert eth0["ip_addresses"] == [ + {"family": "ipv4", "address": "192.168.1.5", "netmask": "255.255.255.0"}, + {"family": "ipv4", "address": "10.0.0.5", "netmask": "255.255.255.0"}, + {"family": "ipv6", "address": "fe80::1", "netmask": "ffff:ffff:ffff:ffff::"}, + {"family": "ipv6", "address": "2001:db8::1", "netmask": "ffff:ffff:ffff:ffff::"}, + ] + # legacy single-IPv4 fields are kept for backward compatibility + assert eth0["ip_address"] == "10.0.0.5" + assert eth0["netmask"] == "255.255.255.0" + # link attributes and up status come from net_if_stats (flags split into a list) + assert eth0["status"] == "up" + assert eth0["speed"] == 1000 + assert eth0["mtu"] == 1500 + assert eth0["flags"] == ["up", "broadcast", "running", "multicast"] + + +@patch("gns3server.utils.interfaces.psutil.net_if_stats", _fake_net_if_stats) +@patch("gns3server.utils.interfaces.psutil.net_if_addrs", _fake_net_if_addrs) +def test_interfaces_with_no_address(config): + + result = {iface["name"]: iface for iface in interfaces()} + eth1 = result["eth1"] + assert eth1["ip_addresses"] == [] + assert eth1["ip_address"] == "" + assert eth1["netmask"] == "" + assert eth1["status"] == "down" + assert eth1["flags"] == ["broadcast"] + + +@patch("gns3server.utils.interfaces.psutil.net_if_stats", _fake_net_if_stats) +@patch("gns3server.utils.interfaces.psutil.net_if_addrs", _fake_net_if_addrs) +def test_interfaces_with_ipv6_only(config): + + result = {iface["name"]: iface for iface in interfaces()} + eth2 = result["eth2"] + assert eth2["ip_addresses"] == [ + {"family": "ipv6", "address": "2001:db8::2", "netmask": "ffff:ffff:ffff:ffff::"}, + ] + assert eth2["ip_address"] == "" + assert eth2["status"] == "up" + assert eth2["mtu"] == 1500 + + +@patch("gns3server.utils.interfaces.psutil.net_if_stats", _fake_net_if_stats) +@patch("gns3server.utils.interfaces.psutil.net_if_addrs", _fake_net_if_addrs) +def test_interfaces_without_stats_entry(config): + # an interface present in net_if_addrs but missing from net_if_stats falls + # back to neutral defaults instead of crashing + result = {iface["name"]: iface for iface in interfaces()} + eth3 = result["eth3"] + assert eth3["status"] == "down" + assert eth3["speed"] == 0 + assert eth3["mtu"] == 0 + assert eth3["flags"] == [] def test_has_netmask(config): From 0ed1715fe667864308388c2aec9b1c468e75794f Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 10 Jul 2026 14:55:21 +0800 Subject: [PATCH 2/2] Use typed InterfaceStatus enum and simplify stats fallback in interfaces() - Change `status: str` to `status: InterfaceStatus` (str Enum) in the HostInterface schema, matching the existing IPAddressFamily pattern. - Use default-then-override pattern in interfaces() to eliminate the `else` branch and deduplicate defaults. --- gns3server/schemas/compute/cloud_nodes.py | 8 +++++++- gns3server/utils/interfaces.py | 17 +++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/gns3server/schemas/compute/cloud_nodes.py b/gns3server/schemas/compute/cloud_nodes.py index 1f2877fc6..28e01c72d 100644 --- a/gns3server/schemas/compute/cloud_nodes.py +++ b/gns3server/schemas/compute/cloud_nodes.py @@ -34,6 +34,12 @@ class IPAddressFamily(str, Enum): ipv6 = "ipv6" +class InterfaceStatus(str, Enum): + + up = "up" + down = "down" + + class HostInterfaceIPAddress(BaseModel): """ An IP address (with optional netmask) bound to a host interface. @@ -55,7 +61,7 @@ class HostInterface(BaseModel): ip_addresses: List[HostInterfaceIPAddress] = Field( default_factory=list, description="All IPv4 and IPv6 addresses on this interface" ) - status: str = Field("down", description="Interface status (up or down)") + status: InterfaceStatus = Field(InterfaceStatus.down, description="Interface status (up or down)") speed: int = Field(0, description="Interface speed in Mbit/s (0 if unknown)") mtu: int = Field(0, description="Interface MTU") flags: List[str] = Field(default_factory=list, description="Interface flags") diff --git a/gns3server/utils/interfaces.py b/gns3server/utils/interfaces.py index e7292bea5..5564e2deb 100644 --- a/gns3server/utils/interfaces.py +++ b/gns3server/utils/interfaces.py @@ -233,6 +233,10 @@ def interfaces(): # Operational state and link attributes (speed/mtu/flags) come from # psutil.net_if_stats(). An interface present in net_if_addrs is normally # also present here; fall back to neutral defaults when it is not. + status = "down" + speed = 0 + mtu = 0 + flags = [] stats = net_if_stats.get(interface) if stats is not None: status = "up" if stats.isup else "down" @@ -240,14 +244,11 @@ def interfaces(): mtu = stats.mtu # psutil returns flags either as a comma-separated string (>= 6.0) or # as a list (older versions); normalize to a list for a stable shape. - flags = stats.flags - if isinstance(flags, str): - flags = [flag for flag in flags.split(",") if flag] - else: - status = "down" - speed = 0 - mtu = 0 - flags = [] + f = stats.flags + if isinstance(f, str): + flags = [flag for flag in f.split(",") if flag] + else: + flags = f results.append( { "id": interface,