Compare commits

...

26 Commits

Author SHA1 Message Date
Jeremy Grossmann
a3e85b7041
Merge pull request #2853 from hjicks/master
add support basic support for OpenBSD.
2026-08-18 18:01:15 +02:00
chara
80da7da515 add support basic support for OpenBSD.
there remains some work on ubridge, but this seems to work so far.
2026-08-13 22:24:58 +03:30
Jeremy Grossmann
d2f73cae28
Merge pull request #2842 from GNS3/release/v2.2.61
Release v2.2.61
2026-07-30 19:15:25 +02:00
grossmj
da2942c139
Release v2.2.61 2026-07-30 12:53:08 +02:00
grossmj
5485eef3ad
Sync appliances 2026-07-29 18:52:23 +02:00
Jeremy Grossmann
ccf4b3f9db
Merge pull request #2840 from GNS3/fix-unvalidated-symlink
Fix unvalidated symlink creation in import_project
2026-07-29 18:51:21 +02:00
grossmj
b797981a65
test: add tests for unvalidated symlink creation in import_project 2026-07-29 18:48:31 +02:00
grossmj
a225622a6e
fix(import): unvalidated symlink creation in import_project 2026-07-29 18:10:52 +02:00
Jeremy Grossmann
3f19ede8d2
Merge pull request #2839 from GNS3/bugfix/2838
Fix addition of QEMU RNG device in 2.2.60 causes interface names to change
2026-07-29 18:00:16 +02:00
Jeremy Grossmann
668169d1cc
Merge branch '2.2' into bugfix/2838 2026-07-29 17:55:53 +02:00
grossmj
66b39f0616
Merge remote-tracking branch 'origin/master' into 2.2 2026-07-29 17:54:18 +02:00
Jeremy Grossmann
7efef28d4f
Merge branch '2.2' into bugfix/2838 2026-07-29 17:50:01 +02:00
grossmj
d9140d2f8c
fix(qemu): move the bios options after the network ones 2026-07-29 17:47:03 +02:00
grossmj
e2657febf0
fix(qemu): fix addition of QEMU RNG device causes interface names to change 2026-07-29 17:23:28 +02:00
Jeremy Grossmann
d161f1997d
Merge pull request #2837 from my-code-is-grey/ghcr_lowercase
fix: lowercase GHCR image name in docker-build workflow
2026-07-28 17:28:41 +02:00
my-code-is-grey
37490f2c74 bugfix: docker workflow needs lowercase name 2026-07-27 22:27:48 -04:00
Jeremy Grossmann
9b1d7d44bf
Merge pull request #2836 from iskanred/bugfix/2835
fix(qemu): remove trailing space from RNG object argument
2026-07-27 20:42:29 +02:00
Iskander Nafikov
9562c9d828 fix(qemu): remove trailing space from RNG object argument 2026-07-28 00:27:55 +08:00
Jeremy Grossmann
6c2a980afc
Merge pull request #2833 from Sanjays2402/fix/delete-project-does-not-start-nodes
Do not start nodes when deleting a project
2026-07-26 09:23:12 +02:00
Sanjay Santhanam
00ac2c19bd Do not start nodes when deleting a project
Project.delete() calls open() to rebuild the internal data structures
needed for cleanup. open() schedules start_all() when the project has
auto_start enabled, so deleting an auto-start project actually launched
every node process, allocating ports and consuming resources, only for
close() to kill them moments later.

open() now takes an auto_start argument (default True, so normal opens
are unchanged) and delete() passes auto_start=False.

Fixes #2784
2026-07-25 20:00:43 -07:00
Jeremy Grossmann
6742d09a70
Merge pull request #2831 from Sanjays2402/fix/docker-stop-state-check
fix: correct always-true state check in DockerVM.stop()
2026-07-25 19:59:28 +02:00
Sanjay Santhanam
41e8777609 fix: correct always-true state check in DockerVM.stop()
The condition 'state != "stopped" or state != "exited"' is a tautology,
so the state check was a no-op and a stop request was sent even for a
container that had already exited.

_get_container_state() never returns "stopped" (only "running",
"paused" or "exited"), so the intended negation of the condition used in
_fix_permissions() requires 'and', not 'or' (De Morgan's law).

Added a regression test asserting no stop query is issued for an
already-exited container.
2026-07-25 10:30:16 -07:00
Jeremy Grossmann
20868aa233
Merge pull request #2830 from rafael-33/master
docs: add structured bug-report template
2026-07-24 22:55:36 +02:00
rafael-33
27c6535ad0
add bug report issue template 2026-07-24 22:51:44 +02:00
grossmj
c75e1ba0be
Development on 2.2.61.dev1 2026-07-15 20:45:33 +02:00
Jeremy Grossmann
c1e845cb22
Merge pull request #2819 from GNS3/release/v2.2.60
Release v2.2.60
2026-07-15 20:44:04 +02:00
15 changed files with 270 additions and 50 deletions

40
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@ -0,0 +1,40 @@
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

View File

@ -28,6 +28,13 @@ jobs:
echo "stable=false" >> $GITHUB_OUTPUT echo "stable=false" >> $GITHUB_OUTPUT
fi 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 - name: Set up Docker Buildx
if: steps.ver.outputs.stable == 'true' if: steps.ver.outputs.stable == 'true'
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@ -57,5 +64,5 @@ jobs:
tags: | tags: |
${{ env.DOCKERHUB_ORG }}/${{ github.event.repository.name }}:${{ steps.ver.outputs.tag }} ${{ env.DOCKERHUB_ORG }}/${{ github.event.repository.name }}:${{ steps.ver.outputs.tag }}
${{ env.DOCKERHUB_ORG }}/${{ github.event.repository.name }}:latest ${{ env.DOCKERHUB_ORG }}/${{ github.event.repository.name }}:latest
ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ steps.ver.outputs.tag }} ghcr.io/${{ steps.names.outputs.owner }}/${{ steps.names.outputs.repo }}:${{ steps.ver.outputs.tag }}
ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest ghcr.io/${{ steps.names.outputs.owner }}/${{ steps.names.outputs.repo }}:latest

View File

@ -1,5 +1,14 @@
# Change Log # 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 ## 2.2.60 15/07/2026
* Sync appliances * Sync appliances

View File

@ -0,0 +1,59 @@
{
"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"
}
}
]
}

View File

@ -311,7 +311,7 @@ class Cloud(BaseNode):
if not port_info["interface"] in network_interfaces: 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)) raise NodeError("Interface '{}' could not be found on this system, please update '{}'".format(port_info["interface"], self.name))
if sys.platform.startswith("linux"): if sys.platform.startswith("linux") or sys.platform.startswith("openbsd"):
await self._add_linux_ethernet(port_info, bridge_name) await self._add_linux_ethernet(port_info, bridge_name)
elif sys.platform.startswith("darwin"): elif sys.platform.startswith("darwin"):
await self._add_osx_ethernet(port_info, bridge_name) await self._add_osx_ethernet(port_info, bridge_name)

View File

@ -869,7 +869,7 @@ class DockerVM(BaseNode):
await self._fix_permissions() await self._fix_permissions()
state = await self._get_container_state() state = await self._get_container_state()
if state != "stopped" or state != "exited": if state != "stopped" and state != "exited":
# t=5 number of seconds to wait before killing the container # t=5 number of seconds to wait before killing the container
try: try:
await self.manager.query("POST", "containers/{}/stop".format(self._cid), params={"t": 5}) await self.manager.query("POST", "containers/{}/stop".format(self._cid), params={"t": 5})

View File

@ -2480,7 +2480,6 @@ class QemuVM(BaseNode):
elif sys.platform.startswith("win") or sys.platform.startswith("darwin"): elif sys.platform.startswith("win") or sys.platform.startswith("darwin"):
command.extend(["-enable-hax"]) command.extend(["-enable-hax"])
command.extend(["-boot", "order={}".format(self._boot_priority)]) command.extend(["-boot", "order={}".format(self._boot_priority)])
command.extend(self._bios_option())
command.extend(self._cdrom_option()) command.extend(self._cdrom_option())
command.extend((await self._disk_options())) command.extend((await self._disk_options()))
command.extend(self._linux_boot_options()) command.extend(self._linux_boot_options())
@ -2498,6 +2497,8 @@ class QemuVM(BaseNode):
raise QemuError("Console type {} is unknown".format(self._console_type)) raise QemuError("Console type {} is unknown".format(self._console_type))
command.extend(self._monitor_options()) command.extend(self._monitor_options())
command.extend((await self._network_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": if self.on_close != "save_vm_state":
await self._clear_save_vm_stated() await self._clear_save_vm_stated()
else: else:

View File

@ -166,25 +166,53 @@ async def import_project(
project = await controller.load_project(dot_gns3_path, load=False) project = await controller.load_project(dot_gns3_path, load=False)
return project return project
def _create_symbolic_links(zip_file, path): def _create_symbolic_links(zip_file, path):
""" """
Manually create symbolic links (if any) because ZipFile does not support it. Manually create symbolic links (if any) because ZipFile does not support it.
Refuse any target that escapes `path`.
:param zip_file: ZipFile instance :param zip_file: ZipFile instance
:param path: project location :param path: project location
""" """
path_root = os.path.realpath(path) + os.sep
for zip_info in zip_file.infolist(): for zip_info in zip_file.infolist():
if stat.S_ISLNK(zip_info.external_attr >> 16): if not stat.S_ISLNK(zip_info.external_attr >> 16):
continue
symlink_target = zip_file.read(zip_info.filename).decode() symlink_target = zip_file.read(zip_info.filename).decode()
symlink_path = os.path.join(path, zip_info.filename) 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: try:
# remove the regular file and replace it by a symbolic link
os.remove(symlink_path) os.remove(symlink_path)
os.symlink(symlink_target, symlink_path) os.symlink(symlink_target, symlink_path)
except OSError as e: except OSError as e:
raise aiohttp.web.HTTPConflict(text=f"Cannot create symbolic link: {e}") raise aiohttp.web.HTTPConflict(text=f"Cannot create symbolic link: {e}")
def regenerate_topology_ids(topology, new_project_path, reset_mac_addresses=False): def regenerate_topology_ids(topology, new_project_path, reset_mac_addresses=False):
""" """
Regenerate IDs in the topology and move the files of the nodes to match the new IDs. Regenerate IDs in the topology and move the files of the nodes to match the new IDs.

View File

@ -917,7 +917,7 @@ class Project:
if self._status != "opened": if self._status != "opened":
try: try:
await self.open() await self.open(auto_start=False)
except aiohttp.web.HTTPConflict as e: except aiohttp.web.HTTPConflict as e:
# ignore missing images or other conflicts when deleting a project # ignore missing images or other conflicts when deleting a project
log.warning(f"Conflict while deleting project: {e}") log.warning(f"Conflict while deleting project: {e}")
@ -998,9 +998,12 @@ class Project:
return os.path.join(self.path, self._filename) return os.path.join(self.path, self._filename)
@locking @locking
async def open(self): async def open(self, auto_start=True):
""" """
Load topology elements Load topology elements
:param auto_start: whether the nodes may be started when the project
has auto start enabled
""" """
if self._closing: if self._closing:
@ -1114,7 +1117,7 @@ class Project:
self._loading = False self._loading = False
self.emit_controller_notification("project.opened", self.__json__()) self.emit_controller_notification("project.opened", self.__json__())
# Should we start the nodes when project is open # Should we start the nodes when project is open
if self._auto_start: if self._auto_start and auto_start:
# Start all in the background without waiting for completion # Start all in the background without waiting for completion
# we ignore errors because we want to let the user open # we ignore errors because we want to let the user open
# their project and fix it # their project and fix it

View File

@ -57,7 +57,7 @@ class CrashReport:
Report crash to a third party service Report crash to a third party service
""" """
DSN = "https://deee946d000cb5548acebb1cd7c1a1d3@o19455.ingest.us.sentry.io/38482" DSN = "https://89ba0bf8d773c2c95518bfb696d6d86f@o19455.ingest.us.sentry.io/38482"
_instance = None _instance = None
def __init__(self): def __init__(self):

View File

@ -147,7 +147,7 @@ def is_interface_up(interface):
:returns: boolean :returns: boolean
""" """
if sys.platform.startswith("linux"): if sys.platform.startswith("linux") or sys.platform.startswith("openbsd"):
if interface not in psutil.net_if_addrs(): if interface not in psutil.net_if_addrs():
return False return False
@ -234,7 +234,7 @@ def interfaces():
result["special"] = False result["special"] = False
for special_interface in ("lo", "vmnet", "vboxnet", "docker", "lxcbr", for special_interface in ("lo", "vmnet", "vboxnet", "docker", "lxcbr",
"virbr", "ovs-system", "veth", "fw", "p2p", "virbr", "ovs-system", "veth", "fw", "p2p",
"bridge", "vmware", "virtualbox", "gns3"): "bridge", "vmware", "virtualbox", "gns3","veb"):
if result["name"].lower().startswith(special_interface): if result["name"].lower().startswith(special_interface):
result["special"] = True result["special"] = True
for special_interface in ("-nic"): for special_interface in ("-nic"):

View File

@ -23,8 +23,8 @@
# or negative for a release candidate or beta (after the base version # or negative for a release candidate or beta (after the base version
# number has been incremented) # number has been incremented)
__version__ = "2.2.60" __version__ = "2.2.61"
__version_info__ = (2, 2, 60, 0) __version_info__ = (2, 2, 61, 0)
if "dev" in __version__: if "dev" in __version__:
try: try:

View File

@ -1503,3 +1503,19 @@ async def test_read_console_output_with_binary_mode(vm):
with asyncio_patch('gns3server.compute.docker.docker_vm.DockerVM.stop'): with asyncio_patch('gns3server.compute.docker.docker_vm.DockerVM.stop'):
await vm._read_console_output(input_stream, output_stream) await vm._read_console_output(input_stream, output_stream)
output_stream.feed_data.assert_called_once_with(b"test") 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"

View File

@ -19,6 +19,8 @@ import os
import uuid import uuid
import json import json
import zipfile import zipfile
import pytest
import aiohttp
from tests.utils import asyncio_patch, AsyncioMagicMock from tests.utils import asyncio_patch, AsyncioMagicMock
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@ -117,13 +119,12 @@ async def write_file(path, z):
f.write(chunk) f.write(chunk)
async def test_import_project_containing_symlink(tmpdir, controller): @pytest.fixture
def export_project_with_symlink(tmpdir, controller):
async def _export(symlink_target):
project = Project(controller=controller, name="test") project = Project(controller=controller, name="test")
project.dump = MagicMock() project.dump = MagicMock()
path = project.path
project_id = str(uuid.uuid4())
topology = { topology = {
"project_id": str(uuid.uuid4()), "project_id": str(uuid.uuid4()),
"name": "test", "name": "test",
@ -134,12 +135,11 @@ async def test_import_project_containing_symlink(tmpdir, controller):
"version": "2.0.0" "version": "2.0.0"
} }
with open(os.path.join(path, "project.gns3"), 'w+') as f: with open(os.path.join(project.path, "project.gns3"), 'w+') as f:
json.dump(topology, f) json.dump(topology, f)
os.makedirs(os.path.join(path, "vm1", "dynamips")) os.makedirs(os.path.join(project.path, "vm1", "dynamips"))
symlink_path = os.path.join(project.path, "vm1", "dynamips", "symlink") symlink_path = os.path.join(project.path, "vm1", "dynamips", "symlink")
symlink_target = "/tmp/anywhere"
os.symlink(symlink_target, symlink_path) os.symlink(symlink_target, symlink_path)
zip_path = str(tmpdir / "project.zip") zip_path = str(tmpdir / "project.zip")
@ -148,6 +148,20 @@ async def test_import_project_containing_symlink(tmpdir, controller):
await export_project(z, project, str(tmpdir), include_images=False) await export_project(z, project, str(tmpdir), include_images=False)
await write_file(zip_path, z) 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_id = str(uuid.uuid4())
symlink_target = "../symlink_target"
zip_path = await export_project_with_symlink(symlink_target)
with open(zip_path, "rb") as f: with open(zip_path, "rb") as f:
project = await import_project(controller, project_id, f) project = await import_project(controller, project_id, f)
@ -158,6 +172,36 @@ async def test_import_project_containing_symlink(tmpdir, controller):
assert os.readlink(symlink_path) == symlink_target 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): async def test_import_upgrade(tmpdir, controller):
""" """
Topology made for previous GNS3 version are upgraded during the process Topology made for previous GNS3 version are upgraded during the process

View File

@ -655,6 +655,19 @@ async def test_delete(project):
assert not os.path.exists(project.path) 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): async def test_dump(projects_dir):
directory = projects_dir directory = projects_dir