Merge pull request #2810 from yueguobin/feature/cloud-host-interface-ip-addresses

Expose full host interface IP addresses and link state in cloud node API
This commit is contained in:
Jeremy Grossmann 2026-07-10 09:19:54 +02:00 committed by GitHub
commit 94a131dab3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 203 additions and 11 deletions

View File

@ -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 {

View File

@ -28,6 +28,28 @@ class HostInterfaceType(str, Enum):
tap = "tap"
class IPAddressFamily(str, Enum):
ipv4 = "ipv4"
ipv6 = "ipv6"
class InterfaceStatus(str, Enum):
up = "up"
down = "down"
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 +58,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: 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")
class EthernetType(str, Enum):

View File

@ -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,48 @@ 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.
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"
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.
f = stats.flags
if isinstance(f, str):
flags = [flag for flag in f.split(",") if flag]
else:
flags = f
results.append(
{
"id": interface,
@ -224,6 +257,11 @@ def interfaces():
"netmask": netmask,
"mac_address": mac_address,
"type": interface_type,
"ip_addresses": ip_addresses,
"status": status,
"speed": speed,
"mtu": mtu,
"flags": flags,
}
)

View File

@ -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']}
]
}

View File

@ -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

View File

@ -16,10 +16,21 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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):