mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-29 05:20:14 +03:00
Compare commits
No commits in common. "master" and "v2.2.60" have entirely different histories.
40
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
40
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -1,40 +0,0 @@
|
||||
name: Bug report
|
||||
description: Report a bug so we can fix it.
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: A clear description of the bug.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: How can we reproduce this? Numbered steps if possible.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What did you expect to happen instead?
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version / commit
|
||||
description: Which version or commit hash are you on?
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: OS, runtime version, anything else that might be relevant.
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs
|
||||
description: Paste any relevant log output. This is automatically rendered as code.
|
||||
render: shell
|
||||
11
.github/workflows/docker-build.yml
vendored
11
.github/workflows/docker-build.yml
vendored
@ -28,13 +28,6 @@ jobs:
|
||||
echo "stable=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set lowercase image name vars
|
||||
if: steps.ver.outputs.stable == 'true'
|
||||
id: names
|
||||
run: |
|
||||
echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=$(echo '${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.ver.outputs.stable == 'true'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@ -64,5 +57,5 @@ jobs:
|
||||
tags: |
|
||||
${{ env.DOCKERHUB_ORG }}/${{ github.event.repository.name }}:${{ steps.ver.outputs.tag }}
|
||||
${{ env.DOCKERHUB_ORG }}/${{ github.event.repository.name }}:latest
|
||||
ghcr.io/${{ steps.names.outputs.owner }}/${{ steps.names.outputs.repo }}:${{ steps.ver.outputs.tag }}
|
||||
ghcr.io/${{ steps.names.outputs.owner }}/${{ steps.names.outputs.repo }}:latest
|
||||
ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ steps.ver.outputs.tag }}
|
||||
ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest
|
||||
|
||||
@ -1,14 +1,5 @@
|
||||
# Change Log
|
||||
|
||||
## 2.2.61 30/07/2026
|
||||
|
||||
* Sync appliances
|
||||
* fix(import): unvalidated symlink creation in import_project
|
||||
* fix(qemu): fix addition of QEMU RNG device causes interface names to change
|
||||
* fix(qemu): remove trailing space from RNG object argument
|
||||
* fix: do not start nodes when deleting a project
|
||||
* fix: correct always-true state check in DockerVM.stop()
|
||||
|
||||
## 2.2.60 15/07/2026
|
||||
|
||||
* Sync appliances
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
{
|
||||
"appliance_id": "b3b90fde-143a-4129-8031-ccbba73c5e02",
|
||||
"name": "armbian",
|
||||
"category": "guest",
|
||||
"description": "A highly optimized base operating system specialized for single board computers (SBCs) and its extensive build framework.",
|
||||
"vendor_name": "The Armbian team",
|
||||
"vendor_url": "https://armbian.com/",
|
||||
"documentation_url": "https://docs.armbian.com/",
|
||||
"product_name": "Armbian UEFI x86",
|
||||
"product_url": "https://armbian.com/boards/uefi-x86",
|
||||
"registry_version": 4,
|
||||
"status": "stable",
|
||||
"maintainer": "GNS3 Team",
|
||||
"maintainer_email": "developers@gns3.net",
|
||||
"usage": "By first login you create root password and new sudo user.\n\nBoot disk from UEFI shell, type: FS0:EFI\\BOOT\\BOOTX64 and press <Enter>",
|
||||
"port_name_format": "Ethernet{0}",
|
||||
"qemu": {
|
||||
"adapter_type": "virtio-net-pci",
|
||||
"adapters": 2,
|
||||
"ram": 256,
|
||||
"hda_disk_interface": "virtio",
|
||||
"arch": "x86_64",
|
||||
"console_type": "spice+agent",
|
||||
"uefi": false,
|
||||
"boot_priority": "c",
|
||||
"kvm": "require",
|
||||
"options": "-nographic"
|
||||
},
|
||||
"images": [
|
||||
{
|
||||
"filename": "OVMF-edk2-stable202305.fd",
|
||||
"version": "stable202305",
|
||||
"md5sum": "6c4cf1519fec4a4b95525d9ae562963a",
|
||||
"filesize": 4194304,
|
||||
"download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/",
|
||||
"direct_download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/OVMF-edk2-stable202305.fd.zip/download",
|
||||
"compression": "zip"
|
||||
},
|
||||
{
|
||||
"filename": "Armbian_26.5.1_Uefi-x86_trixie_cloud_6.18.32_minimal.img.qcow2",
|
||||
"version": "Armbian 26.5.1 Minimal (CLI)",
|
||||
"md5sum": "7f4c915668718d6135406de5a6c4fc30",
|
||||
"filesize": 877920512,
|
||||
"download_url": "https://armbian.com/boards/uefi-x86",
|
||||
"direct_download_url": "https://armbian.atomonetworks.com/dl/uefi-x86/archive/Armbian_26.5.1_Uefi-x86_trixie_cloud_6.18.32_minimal.img.qcow2.xz",
|
||||
"compression": "xz"
|
||||
}
|
||||
|
||||
],
|
||||
"versions": [
|
||||
{
|
||||
"name": "Armbian 26.5.1 Minimal (CLI)",
|
||||
"images": {
|
||||
"bios_image": "OVMF-edk2-stable202305.fd",
|
||||
"hda_disk_image": "Armbian_26.5.1_Uefi-x86_trixie_cloud_6.18.32_minimal.img.qcow2"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -311,7 +311,7 @@ class Cloud(BaseNode):
|
||||
if not port_info["interface"] in network_interfaces:
|
||||
raise NodeError("Interface '{}' could not be found on this system, please update '{}'".format(port_info["interface"], self.name))
|
||||
|
||||
if sys.platform.startswith("linux") or sys.platform.startswith("openbsd"):
|
||||
if sys.platform.startswith("linux"):
|
||||
await self._add_linux_ethernet(port_info, bridge_name)
|
||||
elif sys.platform.startswith("darwin"):
|
||||
await self._add_osx_ethernet(port_info, bridge_name)
|
||||
|
||||
@ -869,7 +869,7 @@ class DockerVM(BaseNode):
|
||||
await self._fix_permissions()
|
||||
|
||||
state = await self._get_container_state()
|
||||
if state != "stopped" and state != "exited":
|
||||
if state != "stopped" or state != "exited":
|
||||
# t=5 number of seconds to wait before killing the container
|
||||
try:
|
||||
await self.manager.query("POST", "containers/{}/stop".format(self._cid), params={"t": 5})
|
||||
|
||||
@ -2107,7 +2107,7 @@ class QemuVM(BaseNode):
|
||||
options.extend(["-drive", "if=pflash,format=raw,file={}".format(ovmf_vars_node_path)])
|
||||
|
||||
# edk2 firmware requires a Random Number Generator (RNG) device in order to turn network adapters on
|
||||
options.extend(["-object", "rng-random,filename=/dev/urandom,id=rng0"])
|
||||
options.extend(["-object", "rng-random,filename=/dev/urandom,id=rng0 "])
|
||||
options.extend(["-device", "virtio-rng-pci,rng=rng0"])
|
||||
return options
|
||||
|
||||
@ -2480,6 +2480,7 @@ class QemuVM(BaseNode):
|
||||
elif sys.platform.startswith("win") or sys.platform.startswith("darwin"):
|
||||
command.extend(["-enable-hax"])
|
||||
command.extend(["-boot", "order={}".format(self._boot_priority)])
|
||||
command.extend(self._bios_option())
|
||||
command.extend(self._cdrom_option())
|
||||
command.extend((await self._disk_options()))
|
||||
command.extend(self._linux_boot_options())
|
||||
@ -2497,8 +2498,6 @@ class QemuVM(BaseNode):
|
||||
raise QemuError("Console type {} is unknown".format(self._console_type))
|
||||
command.extend(self._monitor_options())
|
||||
command.extend((await self._network_options()))
|
||||
# bios options must be last to have predictable NIC numbering, see https://github.com/GNS3/gns3-server/issues/2838
|
||||
command.extend(self._bios_option())
|
||||
if self.on_close != "save_vm_state":
|
||||
await self._clear_save_vm_stated()
|
||||
else:
|
||||
|
||||
@ -166,52 +166,24 @@ async def import_project(
|
||||
project = await controller.load_project(dot_gns3_path, load=False)
|
||||
return project
|
||||
|
||||
|
||||
def _create_symbolic_links(zip_file, path):
|
||||
"""
|
||||
Manually create symbolic links (if any) because ZipFile does not support it.
|
||||
Refuse any target that escapes `path`.
|
||||
|
||||
:param zip_file: ZipFile instance
|
||||
:param path: project location
|
||||
"""
|
||||
|
||||
path_root = os.path.realpath(path) + os.sep
|
||||
for zip_info in zip_file.infolist():
|
||||
if not stat.S_ISLNK(zip_info.external_attr >> 16):
|
||||
continue
|
||||
symlink_target = zip_file.read(zip_info.filename).decode()
|
||||
symlink_path = os.path.join(path, zip_info.filename)
|
||||
|
||||
# 1. Reject absolute targets outright.
|
||||
if os.path.isabs(symlink_target):
|
||||
raise aiohttp.web.HTTPConflict(
|
||||
text=f"Symlink {zip_info.filename!r} has absolute target {symlink_target!r}, refusing"
|
||||
)
|
||||
|
||||
# 2. Reject paths where the entry name itself escapes (defence in depth;
|
||||
# extractall normally would already have caught this).
|
||||
member_abs = os.path.realpath(symlink_path)
|
||||
if not (member_abs + os.sep).startswith(path_root) and member_abs + os.sep != path_root:
|
||||
raise aiohttp.web.HTTPConflict(
|
||||
text=f"Symlink entry {zip_info.filename!r} escapes project dir, refusing"
|
||||
)
|
||||
|
||||
# 3. Resolve the symlink target relative to the entry's own parent
|
||||
# directory and verify the resolved real path stays inside `path`.
|
||||
link_dir = os.path.realpath(os.path.dirname(symlink_path))
|
||||
resolved_target = os.path.realpath(os.path.join(link_dir, symlink_target))
|
||||
if not (resolved_target + os.sep).startswith(path_root) and resolved_target + os.sep != path_root:
|
||||
raise aiohttp.web.HTTPConflict(
|
||||
text=f"Symlink {zip_info.filename!r} -> {symlink_target!r} escapes project dir, refusing"
|
||||
)
|
||||
|
||||
try:
|
||||
os.remove(symlink_path)
|
||||
os.symlink(symlink_target, symlink_path)
|
||||
except OSError as e:
|
||||
raise aiohttp.web.HTTPConflict(text=f"Cannot create symbolic link: {e}")
|
||||
|
||||
if stat.S_ISLNK(zip_info.external_attr >> 16):
|
||||
symlink_target = zip_file.read(zip_info.filename).decode()
|
||||
symlink_path = os.path.join(path, zip_info.filename)
|
||||
try:
|
||||
# remove the regular file and replace it by a symbolic link
|
||||
os.remove(symlink_path)
|
||||
os.symlink(symlink_target, symlink_path)
|
||||
except OSError as e:
|
||||
raise aiohttp.web.HTTPConflict(text=f"Cannot create symbolic link: {e}")
|
||||
|
||||
def regenerate_topology_ids(topology, new_project_path, reset_mac_addresses=False):
|
||||
"""
|
||||
|
||||
@ -917,7 +917,7 @@ class Project:
|
||||
|
||||
if self._status != "opened":
|
||||
try:
|
||||
await self.open(auto_start=False)
|
||||
await self.open()
|
||||
except aiohttp.web.HTTPConflict as e:
|
||||
# ignore missing images or other conflicts when deleting a project
|
||||
log.warning(f"Conflict while deleting project: {e}")
|
||||
@ -998,12 +998,9 @@ class Project:
|
||||
return os.path.join(self.path, self._filename)
|
||||
|
||||
@locking
|
||||
async def open(self, auto_start=True):
|
||||
async def open(self):
|
||||
"""
|
||||
Load topology elements
|
||||
|
||||
:param auto_start: whether the nodes may be started when the project
|
||||
has auto start enabled
|
||||
"""
|
||||
|
||||
if self._closing:
|
||||
@ -1117,7 +1114,7 @@ class Project:
|
||||
self._loading = False
|
||||
self.emit_controller_notification("project.opened", self.__json__())
|
||||
# Should we start the nodes when project is open
|
||||
if self._auto_start and auto_start:
|
||||
if self._auto_start:
|
||||
# Start all in the background without waiting for completion
|
||||
# we ignore errors because we want to let the user open
|
||||
# their project and fix it
|
||||
|
||||
@ -57,7 +57,7 @@ class CrashReport:
|
||||
Report crash to a third party service
|
||||
"""
|
||||
|
||||
DSN = "https://89ba0bf8d773c2c95518bfb696d6d86f@o19455.ingest.us.sentry.io/38482"
|
||||
DSN = "https://deee946d000cb5548acebb1cd7c1a1d3@o19455.ingest.us.sentry.io/38482"
|
||||
_instance = None
|
||||
|
||||
def __init__(self):
|
||||
|
||||
@ -147,7 +147,7 @@ def is_interface_up(interface):
|
||||
:returns: boolean
|
||||
"""
|
||||
|
||||
if sys.platform.startswith("linux") or sys.platform.startswith("openbsd"):
|
||||
if sys.platform.startswith("linux"):
|
||||
|
||||
if interface not in psutil.net_if_addrs():
|
||||
return False
|
||||
@ -234,7 +234,7 @@ def interfaces():
|
||||
result["special"] = False
|
||||
for special_interface in ("lo", "vmnet", "vboxnet", "docker", "lxcbr",
|
||||
"virbr", "ovs-system", "veth", "fw", "p2p",
|
||||
"bridge", "vmware", "virtualbox", "gns3","veb"):
|
||||
"bridge", "vmware", "virtualbox", "gns3"):
|
||||
if result["name"].lower().startswith(special_interface):
|
||||
result["special"] = True
|
||||
for special_interface in ("-nic"):
|
||||
|
||||
@ -23,8 +23,8 @@
|
||||
# or negative for a release candidate or beta (after the base version
|
||||
# number has been incremented)
|
||||
|
||||
__version__ = "2.2.61"
|
||||
__version_info__ = (2, 2, 61, 0)
|
||||
__version__ = "2.2.60"
|
||||
__version_info__ = (2, 2, 60, 0)
|
||||
|
||||
if "dev" in __version__:
|
||||
try:
|
||||
|
||||
@ -1503,19 +1503,3 @@ async def test_read_console_output_with_binary_mode(vm):
|
||||
with asyncio_patch('gns3server.compute.docker.docker_vm.DockerVM.stop'):
|
||||
await vm._read_console_output(input_stream, output_stream)
|
||||
output_stream.feed_data.assert_called_once_with(b"test")
|
||||
|
||||
|
||||
async def test_stop_exited_container_no_stop_query(vm):
|
||||
|
||||
vm._ubridge_hypervisor = None
|
||||
vm._fix_permissions = MagicMock()
|
||||
|
||||
with asyncio_patch("gns3server.compute.docker.DockerVM._get_container_state", return_value="exited"):
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.query") as mock_query:
|
||||
vm._permissions_fixed = False
|
||||
await vm.stop()
|
||||
assert not any(
|
||||
call.args[:2] == ("POST", "containers/e90e34656842/stop")
|
||||
for call in mock_query.mock_calls
|
||||
)
|
||||
assert vm.status == "stopped"
|
||||
|
||||
@ -19,8 +19,6 @@ import os
|
||||
import uuid
|
||||
import json
|
||||
import zipfile
|
||||
import pytest
|
||||
import aiohttp
|
||||
|
||||
from tests.utils import asyncio_patch, AsyncioMagicMock
|
||||
from unittest.mock import patch, MagicMock
|
||||
@ -119,48 +117,36 @@ async def write_file(path, z):
|
||||
f.write(chunk)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def export_project_with_symlink(tmpdir, controller):
|
||||
async def _export(symlink_target):
|
||||
project = Project(controller=controller, name="test")
|
||||
project.dump = MagicMock()
|
||||
async def test_import_project_containing_symlink(tmpdir, controller):
|
||||
|
||||
topology = {
|
||||
"project_id": str(uuid.uuid4()),
|
||||
"name": "test",
|
||||
"auto_open": True,
|
||||
"auto_start": True,
|
||||
"topology": {
|
||||
},
|
||||
"version": "2.0.0"
|
||||
}
|
||||
|
||||
with open(os.path.join(project.path, "project.gns3"), 'w+') as f:
|
||||
json.dump(topology, f)
|
||||
|
||||
os.makedirs(os.path.join(project.path, "vm1", "dynamips"))
|
||||
symlink_path = os.path.join(project.path, "vm1", "dynamips", "symlink")
|
||||
os.symlink(symlink_target, symlink_path)
|
||||
|
||||
zip_path = str(tmpdir / "project.zip")
|
||||
with aiozipstream.ZipFile() as z:
|
||||
with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS"),):
|
||||
await export_project(z, project, str(tmpdir), include_images=False)
|
||||
await write_file(zip_path, z)
|
||||
|
||||
return zip_path
|
||||
|
||||
return _export
|
||||
|
||||
|
||||
async def test_import_project_containing_symlink(controller, export_project_with_symlink):
|
||||
"""
|
||||
Test importing a project containing a valid symlink (target inside the project directory).
|
||||
"""
|
||||
project = Project(controller=controller, name="test")
|
||||
project.dump = MagicMock()
|
||||
path = project.path
|
||||
|
||||
project_id = str(uuid.uuid4())
|
||||
symlink_target = "../symlink_target"
|
||||
zip_path = await export_project_with_symlink(symlink_target)
|
||||
topology = {
|
||||
"project_id": str(uuid.uuid4()),
|
||||
"name": "test",
|
||||
"auto_open": True,
|
||||
"auto_start": True,
|
||||
"topology": {
|
||||
},
|
||||
"version": "2.0.0"
|
||||
}
|
||||
|
||||
with open(os.path.join(path, "project.gns3"), 'w+') as f:
|
||||
json.dump(topology, f)
|
||||
|
||||
os.makedirs(os.path.join(path, "vm1", "dynamips"))
|
||||
symlink_path = os.path.join(project.path, "vm1", "dynamips", "symlink")
|
||||
symlink_target = "/tmp/anywhere"
|
||||
os.symlink(symlink_target, symlink_path)
|
||||
|
||||
zip_path = str(tmpdir / "project.zip")
|
||||
with aiozipstream.ZipFile() as z:
|
||||
with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS"),):
|
||||
await export_project(z, project, str(tmpdir), include_images=False)
|
||||
await write_file(zip_path, z)
|
||||
|
||||
with open(zip_path, "rb") as f:
|
||||
project = await import_project(controller, project_id, f)
|
||||
@ -172,36 +158,6 @@ async def test_import_project_containing_symlink(controller, export_project_with
|
||||
assert os.readlink(symlink_path) == symlink_target
|
||||
|
||||
|
||||
async def test_import_project_containing_absolute_symlink(controller, export_project_with_symlink):
|
||||
"""
|
||||
Test importing a project containing an absolute symlink.
|
||||
This should fail because absolute symlinks are not allowed for security reasons.
|
||||
"""
|
||||
|
||||
project_id = str(uuid.uuid4())
|
||||
symlink_target = "/tmp/anywhere"
|
||||
zip_path = await export_project_with_symlink(symlink_target)
|
||||
|
||||
with pytest.raises(aiohttp.web.HTTPConflict):
|
||||
with open(zip_path, "rb") as f:
|
||||
await import_project(controller, project_id, f)
|
||||
|
||||
|
||||
async def test_import_project_containing_escaping_symlink(controller, export_project_with_symlink):
|
||||
"""
|
||||
Test importing a project containing a symlink that escapes the project directory.
|
||||
This should fail because symlinks that escape the project directory are not allowed for security reasons.
|
||||
"""
|
||||
|
||||
project_id = str(uuid.uuid4())
|
||||
symlink_target = "../../../../symlink_target"
|
||||
zip_path = await export_project_with_symlink(symlink_target)
|
||||
|
||||
with pytest.raises(aiohttp.web.HTTPConflict):
|
||||
with open(zip_path, "rb") as f:
|
||||
await import_project(controller, project_id, f)
|
||||
|
||||
|
||||
async def test_import_upgrade(tmpdir, controller):
|
||||
"""
|
||||
Topology made for previous GNS3 version are upgraded during the process
|
||||
|
||||
@ -655,19 +655,6 @@ async def test_delete(project):
|
||||
assert not os.path.exists(project.path)
|
||||
|
||||
|
||||
async def test_delete_does_not_start_nodes(project):
|
||||
"""
|
||||
Deleting a project must not start its nodes, even when auto_start is enabled.
|
||||
"""
|
||||
|
||||
project.auto_start = True
|
||||
project.dump()
|
||||
await project.close()
|
||||
project.start_all = AsyncioMagicMock()
|
||||
await project.delete()
|
||||
assert not project.start_all.called
|
||||
|
||||
|
||||
async def test_dump(projects_dir):
|
||||
|
||||
directory = projects_dir
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user