From dd69a01fba9fd0b5190127b5a51e9c1fc04cd426 Mon Sep 17 00:00:00 2001 From: tikD1337 <199419268+tikD1337@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:56:49 +0200 Subject: [PATCH] fix(controller): make node names from template names valid hostnames Creating a node from a template whose name has characters invalid in a hostname, such as "MikroTik CCR1072-1G-8S+", failed on the compute with "'MikroTikCCR1072-1G-8S+-1' is an invalid name to create a Qemu node": the template name was only stripped of spaces. Replace the other invalid characters by hyphens, following the rules the compute validates node names with (RFC 1123 for Qemu and Docker, IOS for Dynamips and IOU), as project import already does for existing nodes. Co-Authored-By: Claude Opus 5.5 --- gns3server/controller/project.py | 3 ++- gns3server/utils/hostname.py | 19 +++++++++++++++++++ tests/controller/test_project.py | 29 +++++++++++++++++++++++++++++ tests/utils/test_hostname.py | 19 +++++++++++++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index e548d34e6..501e9f4e5 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -38,6 +38,7 @@ from ..config import Config from ..utils.application_id import get_next_application_id, is_iol_runner_environment from ..utils.asyncio import aiozipstream, locking, wait_run_in_executor from ..utils.asyncio.pool import Pool +from ..utils.hostname import template_name_to_node_name from ..utils.packet_filter_validation import validate_bpf_syntax from ..utils.path import check_path_allowed, get_default_project_directory from .compute import ComputeError @@ -622,7 +623,7 @@ class Project: log.info(f'Creating node from template "{template_name}" on compute "{compute.name}" [{compute.id}]') default_name_format = template.pop("default_name_format", "{name}-{0}") if name is None: - name = default_name_format.replace("{name}", template_name) + name = default_name_format.replace("{name}", template_name_to_node_name(template_name, node_type)) # 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 {} diff --git a/gns3server/utils/hostname.py b/gns3server/utils/hostname.py index bb9291e3f..e2dac7750 100644 --- a/gns3server/utils/hostname.py +++ b/gns3server/utils/hostname.py @@ -110,3 +110,22 @@ def to_rfc1123_hostname(name: str) -> str: name = name[:253] return name + + +def template_name_to_node_name(name: str, node_type: str) -> str: + """ + Convert a template name to the base of the names of the nodes created from it. + + Spaces are removed. For the node types whose names the compute validates as + hostnames, the other invalid characters (e.g. "+" in a model name) are replaced + by hyphens: RFC 1123 for Qemu and Docker, IOS for Dynamips and IOU. + """ + + name = name.replace(" ", "") + if node_type in ("qemu", "docker"): + name = re.sub(r"[^a-zA-Z0-9.-]", "-", name) + elif node_type in ("dynamips", "iou"): + name = re.sub(r"[^a-zA-Z0-9-]", "-", name) + else: + return name + return name.strip("-.") or node_type diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py index b41c7cb53..9ea1c6726 100644 --- a/tests/controller/test_project.py +++ b/tests/controller/test_project.py @@ -253,6 +253,35 @@ async def test_add_node_from_template_seeds_default_credentials(controller): assert "default_password" not in node.properties +@pytest.mark.asyncio +async def test_add_node_from_template_name_invalid_in_hostname(controller): + """ + A template name with characters invalid in a hostname (e.g. "+" in a model name) + gives node names the compute accepts. + """ + + 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": "MikroTik CCR1072-1G-8S+", + "template_type": "qemu", + "compute_id": "local", + "default_name_format": "{name}-{0}", + "properties": {}, + } + + node = await project.add_node_from_template(template) + assert node.name == "MikroTikCCR1072-1G-8S-1" + + @pytest.mark.asyncio async def test_add_node_non_local(controller): """ diff --git a/tests/utils/test_hostname.py b/tests/utils/test_hostname.py index e802a40a5..3648a6825 100644 --- a/tests/utils/test_hostname.py +++ b/tests/utils/test_hostname.py @@ -14,6 +14,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import pytest + from gns3server.utils import hostname @@ -118,3 +120,20 @@ def test_rfc1123_conversion_hostname_with_all_numeric_tld_replaced(): def rfc1123_hostname_with_multiple_consecutive_invalid_characters(): assert hostname.to_rfc1123_hostname("hostname!!!.example..com") == "hostname---.example.com" + + +@pytest.mark.parametrize( + "name,node_type,expected", + [ + ("MikroTik CCR1072-1G-8S+", "qemu", "MikroTikCCR1072-1G-8S"), + ("MikroTik RB450G", "qemu", "MikroTikRB450G"), + ("Cisco IOSv 15.9", "qemu", "CiscoIOSv15.9"), + ("ubuntu:22.04", "docker", "ubuntu-22.04"), + ("IOU L2 15.2", "iou", "IOUL215-2"), + ("c7200+", "dynamips", "c7200"), + ("PC 1+", "vpcs", "PC1+"), + ("+++", "qemu", "qemu"), + ], +) +def test_template_name_to_node_name(name, node_type, expected): + assert hostname.template_name_to_node_name(name, node_type) == expected