From 92d4e3d37851c5951cca1efdb9bd5918ca23f4b8 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 17 Aug 2026 00:25:33 +0800 Subject: [PATCH] nodes: per-node default credentials seeded from the template Add default_username/default_password as controller-only node properties (the netmiko_device_type pattern): they are not sent to the compute, persist with the project topology and can be updated or cleared per node. Creating a node from a template seeds them from the template appliance metadata, and the metadata itself is dropped there so it never leaks into the node properties. --- gns3server/controller/node.py | 22 ++++++++++++ gns3server/controller/project.py | 6 ++++ gns3server/schemas/controller/nodes.py | 8 +++++ tests/controller/test_node.py | 47 ++++++++++++++++++++++++++ tests/controller/test_project.py | 42 +++++++++++++++++++++++ 5 files changed, 125 insertions(+) diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index 6135456d1..cd474912f 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -58,6 +58,8 @@ class Node: "category", "console_auto_start", "netmiko_device_type", + "default_username", + "default_password", ] def __init__(self, project, compute, name, node_id=None, node_type=None, template_id=None, **kwargs): @@ -114,6 +116,8 @@ class Node: self._first_port_name = None self._console_auto_start = False self._netmiko_device_type = None + self._default_username = None + self._default_password = None # This properties will be recomputed ignore_properties = ("width", "height", "hover_symbol") @@ -222,6 +226,22 @@ class Node: def netmiko_device_type(self, val): self._netmiko_device_type = val + @property + def default_username(self): + return self._default_username + + @default_username.setter + def default_username(self, val): + self._default_username = val + + @property + def default_password(self): + return self._default_password + + @default_password.setter + def default_password(self, val): + self._default_password = val + @property def properties(self): return self._properties @@ -844,6 +864,8 @@ class Node: "console_type": self._console_type, "console_auto_start": self._console_auto_start, "netmiko_device_type": self._netmiko_device_type, + "default_username": self._default_username, + "default_password": self._default_password, "aux": self._aux, "aux_type": self._aux_type, "properties": self._properties, diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 566e49047..f056f4dab 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -584,6 +584,12 @@ class Project: default_name_format = template.pop("default_name_format", "{name}-{0}") if name is None: name = default_name_format.replace("{name}", template_name) + # the appliance metadata stays template level: only the default + # credentials are seeded on the node (where they can be overridden) + appliance_metadata = template.pop("appliance_metadata", None) or {} + for field in ("default_username", "default_password"): + if appliance_metadata.get(field): + template[field] = appliance_metadata[field] node_id = str(uuid.uuid4()) node = await self.add_node(compute, name, node_id, node_type=node_type, **template) return node diff --git a/gns3server/schemas/controller/nodes.py b/gns3server/schemas/controller/nodes.py index 7347cacd7..85672ffb8 100644 --- a/gns3server/schemas/controller/nodes.py +++ b/gns3server/schemas/controller/nodes.py @@ -121,6 +121,14 @@ class NodeBase(BaseModel): description="Device type for Netmiko-based automation tools, overrides the template value", pattern=r"^[a-z0-9_]+$|^$", ) + default_username: Optional[str] = Field( + None, + description="Default username to log into the node, seeded from the template appliance metadata", + ) + default_password: Optional[str] = Field( + None, + description="Default password to log into the node, seeded from the template appliance metadata", + ) aux: Optional[int] = Field(None, gt=0, le=65535, description="Auxiliary console TCP port") aux_type: Optional[ConsoleType] = None properties: Optional[dict] = Field(default_factory=dict, description="Properties specific to an emulator") diff --git a/tests/controller/test_node.py b/tests/controller/test_node.py index 0edb4a0f9..a20405637 100644 --- a/tests/controller/test_node.py +++ b/tests/controller/test_node.py @@ -143,6 +143,8 @@ def test_json(node, compute): "custom_adapters": [], "console_auto_start": False, "netmiko_device_type": None, + "default_username": None, + "default_password": None, "ports": [ { "adapter_number": 0, @@ -181,6 +183,8 @@ def test_json(node, compute): "tags": [], "console_auto_start": False, "netmiko_device_type": None, + "default_username": None, + "default_password": None, } @@ -420,6 +424,49 @@ def test_netmiko_device_type_from_template_kwargs(compute, project): assert node.asdict(topology_dump=True)["netmiko_device_type"] == "nokia_srl" +@pytest.mark.asyncio +async def test_update_default_credentials(node, compute): + """ + default_username/default_password are controller-only properties: updating + them must not call the compute and must be persisted in the node json. + """ + + compute.put = AsyncioMagicMock() + node._project.emit_notification = AsyncioMagicMock() + node._project.dump = MagicMock() + + await node.update(default_username="admin", default_password="secret") + assert not compute.put.called + assert node.default_username == "admin" + assert node.default_password == "secret" + assert node.asdict()["default_username"] == "admin" + assert node.asdict(topology_dump=True)["default_password"] == "secret" + + # credentials never leak into the compute properties + assert "default_username" not in node.properties + assert "default_password" not in node.properties + + # both fields can be cleared with an empty string + await node.update(default_username="", default_password="") + assert node.default_username == "" + assert node.default_password == "" + + +def test_default_credentials_from_template_kwargs(compute, project): + """ + A node created from a template with appliance metadata inherits the + default credentials without sending them to the compute. + """ + + node = Node(project, compute, "test", node_type="vpcs", + default_username="root", default_password="cisco123") + assert node.default_username == "root" + assert node.default_password == "cisco123" + assert "default_username" not in node.properties + assert "default_password" not in node.properties + assert node.asdict(topology_dump=True)["default_username"] == "root" + + @pytest.mark.asyncio async def test_update_no_changes(node, compute): """ diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py index d099c019a..5b5b823e9 100644 --- a/tests/controller/test_project.py +++ b/tests/controller/test_project.py @@ -204,6 +204,48 @@ async def test_add_node_local(controller): project.emit_notification.assert_any_call("node.created", node.asdict()) +@pytest.mark.asyncio +async def test_add_node_from_template_seeds_default_credentials(controller): + """ + The appliance metadata stays template level: creating a node from a + template seeds the default credentials on the node and must not leak + the metadata into the node properties sent to the compute. + """ + + compute = MagicMock() + compute.id = "local" + controller._computes["local"] = compute + project = Project(controller=controller, name="Test") + project.emit_notification = MagicMock() + + response = MagicMock() + response.json = {"console": 2048} + compute.post = AsyncioMagicMock(return_value=response) + + template = { + "name": "VPCS_TEST", + "template_type": "vpcs", + "compute_id": "local", + "default_name_format": "PC{0}", + "properties": {"startup_script": "test.cfg"}, + "appliance_metadata": { + "vendor_name": "Test vendor", + "default_username": "admin", + "default_password": "secret", + }, + } + + node = await project.add_node_from_template(template) + + # credentials seeded from the appliance metadata + assert node.default_username == "admin" + assert node.default_password == "secret" + # the metadata itself never reaches the node properties + assert "appliance_metadata" not in node.properties + assert "default_username" not in node.properties + assert "default_password" not in node.properties + + @pytest.mark.asyncio async def test_add_node_non_local(controller): """