From ddf7d4ca6025132d3e9543b9ad8e839438c11e2c Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 19 Jul 2026 17:41:37 +0800 Subject: [PATCH 1/3] fix(controller): refresh cloud/nat node interfaces from compute on GET Cloud and NAT nodes need live host network interface data. Previously, GET /projects/{project_id}/nodes/{node_id} returned cached properties from creation time, so newly added host interfaces (e.g. kernel bridges created by EthernetSwitch nodes) were invisible until the node was deleted and recreated. Now the controller fetches fresh data from the compute node before returning the response, so host interface changes are reflected immediately. Falls back to cached data if compute is unreachable. --- gns3server/api/routes/controller/nodes.py | 13 ++++++++++++- gns3server/controller/node.py | 8 ++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py index 413839459..d0b27f81f 100644 --- a/gns3server/api/routes/controller/nodes.py +++ b/gns3server/api/routes/controller/nodes.py @@ -243,14 +243,25 @@ async def reload_all_nodes(project: Project = Depends(dep_project)) -> None: raise +# Node types that need live host interface data from compute +_HOST_INTERFACE_NODE_TYPES = {"cloud", "nat"} + + @router.get("/{node_id}", response_model=schemas.Node, dependencies=[Depends(has_privilege("Node.Audit"))]) -def get_node(node: Node = Depends(dep_node)) -> schemas.Node: +async def get_node(node: Node = Depends(dep_node)) -> schemas.Node: """ Return a node from a given project. Required privilege: Node.Audit """ + if node.node_type in _HOST_INTERFACE_NODE_TYPES: + try: + response = await node.get() + await node.parse_node_response(response.json) + except Exception: + # If compute is unreachable, still return cached data + log.warning(f"Could not refresh node {node.id} from compute, returning cached data") return node.asdict() diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index 3378f055f..2046c7cd0 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -639,6 +639,14 @@ class Node: except asyncio.TimeoutError: raise ControllerTimeoutError(f"Timeout when reset console {self._name}") + async def get(self, path="", **kwargs): + """ + HTTP get on the node + """ + return await self._compute.get( + f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}{path}", **kwargs + ) + async def post(self, path, data=None, **kwargs): """ HTTP post on the node From 49435679621fa005dd8d400ed18ce7b41fc9bc6a Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 19 Jul 2026 18:10:51 +0800 Subject: [PATCH 2/3] feat(builtin): NAT node returns its interface IP info in asdict() NAT now includes an 'interfaces' field in its response, filtered to contain only the mapped NAT interface (virbr0 on Linux, vmnet8 on macOS/Windows). This lets connected nodes discover the NAT subnet and gateway address without needing to list all host interfaces. The field format mirrors Cloud.asdict(): name, type, special, and ip_addresses list (both IPv4 and IPv6). --- gns3server/compute/builtin/nodes/nat.py | 18 ++++++ tests/compute/builtin/nodes/test_nat.py | 86 ++++++++++++++++--------- 2 files changed, 73 insertions(+), 31 deletions(-) diff --git a/gns3server/compute/builtin/nodes/nat.py b/gns3server/compute/builtin/nodes/nat.py index f833f4e50..31b96b9fe 100644 --- a/gns3server/compute/builtin/nodes/nat.py +++ b/gns3server/compute/builtin/nodes/nat.py @@ -87,6 +87,23 @@ class Nat(Cloud): return True def asdict(self): + + nat_interface = self._ports_mapping[0].get("interface", "") if self._ports_mapping else "" + + host_interfaces = [] + network_interfaces = gns3server.utils.interfaces.interfaces() + for interface in network_interfaces: + if interface["name"] == nat_interface: + host_interfaces.append( + { + "name": interface["name"], + "type": interface["type"], + "special": interface["special"], + "ip_addresses": interface.get("ip_addresses", []), + } + ) + break + return { "name": self.name, "usage": self.usage, @@ -94,4 +111,5 @@ class Nat(Cloud): "project_id": self.project.id, "status": "started", "ports_mapping": self.ports_mapping, + "interfaces": host_interfaces, } diff --git a/tests/compute/builtin/nodes/test_nat.py b/tests/compute/builtin/nodes/test_nat.py index 60f2da958..bd89aeafe 100644 --- a/tests/compute/builtin/nodes/test_nat.py +++ b/tests/compute/builtin/nodes/test_nat.py @@ -37,7 +37,15 @@ def test_json_gns3vm(on_gns3vm, compute_project): "port_number": 0, "type": "ethernet" } - ] + ], + "interfaces": [ + { + "name": "virbr0", + "type": "ethernet", + "special": True, + "ip_addresses": [], + }, + ], } @@ -47,39 +55,55 @@ def test_json_darwin(darwin_platform, compute_project): {"name": "eth0", "special": False, "type": "ethernet"}, {"name": "vmnet8", "special": True, "type": "ethernet"}]): nat = Nat("nat1", str(uuid.uuid4()), compute_project, MagicMock()) - assert nat.asdict() == { - "name": "nat1", - "usage": "", - "node_id": nat.id, - "project_id": compute_project.id, - "status": "started", - "ports_mapping": [ - { - "interface": "vmnet8", - "name": "nat0", - "port_number": 0, - "type": "ethernet" - } - ] - } + assert nat.asdict() == { + "name": "nat1", + "usage": "", + "node_id": nat.id, + "project_id": compute_project.id, + "status": "started", + "ports_mapping": [ + { + "interface": "vmnet8", + "name": "nat0", + "port_number": 0, + "type": "ethernet" + } + ], + "interfaces": [ + { + "name": "vmnet8", + "type": "ethernet", + "special": True, + "ip_addresses": [], + }, + ], + } def test_json_windows_with_full_name_of_interface(windows_platform, project): with patch("gns3server.utils.interfaces.interfaces", return_value=[ {"name": "VMware Network Adapter VMnet8", "special": True, "type": "ethernet"}]): nat = Nat("nat1", str(uuid.uuid4()), project, MagicMock()) - assert nat.asdict() == { - "name": "nat1", - "usage": "", - "node_id": nat.id, - "project_id": project.id, - "status": "started", - "ports_mapping": [ - { - "interface": "VMware Network Adapter VMnet8", - "name": "nat0", - "port_number": 0, - "type": "ethernet" - } - ] - } + assert nat.asdict() == { + "name": "nat1", + "usage": "", + "node_id": nat.id, + "project_id": project.id, + "status": "started", + "ports_mapping": [ + { + "interface": "VMware Network Adapter VMnet8", + "name": "nat0", + "port_number": 0, + "type": "ethernet" + } + ], + "interfaces": [ + { + "name": "VMware Network Adapter VMnet8", + "type": "ethernet", + "special": True, + "ip_addresses": [], + }, + ], + } From 977d9628e53604d1ba54276a4af5dddd04c3d7bb Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 19 Jul 2026 18:21:22 +0800 Subject: [PATCH 3/3] fix(builtin): hide GNS3 internal bridge interfaces from Cloud node Filter out host interfaces whose name starts with 'gns3' (e.g. EthernetSwitch kernel bridges) from Cloud.asdict() interfaces list. Other special interfaces (virbr0, docker0, etc.) remain visible. --- gns3server/compute/builtin/nodes/cloud.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gns3server/compute/builtin/nodes/cloud.py b/gns3server/compute/builtin/nodes/cloud.py index d71669198..62cfb45ba 100644 --- a/gns3server/compute/builtin/nodes/cloud.py +++ b/gns3server/compute/builtin/nodes/cloud.py @@ -82,6 +82,9 @@ class Cloud(BaseNode): host_interfaces = [] network_interfaces = gns3server.utils.interfaces.interfaces() for interface in network_interfaces: + # Hide GNS3 internal bridges (e.g. EthernetSwitch kernel bridges) + if interface["name"].lower().startswith("gns3"): + continue host_interfaces.append( { "name": interface["name"],