pull/2160/head
grossmj 1 year ago
commit 27d9063e56

@ -1,5 +1,17 @@
# Change Log
## 2.2.36 04/01/2023
* Install web-ui v2.2.36
* Add Trusted Platform Module (TPM) support for Qemu VMs
* Require Dynamips 0.2.23 and bind Dynamips hypervisor on 127.0.0.1
* Delete the built-in appliance directory before installing updated files
* Use a stock BusyBox for the Docker integration
* Overwrite built-in appliance files when starting a more recent version of the server
* Fix reset console. Fixes #1619
* Only use importlib_resources for Python <= 3.9. Fixes #2147
* Support when the user field defined in Docker container is an ID. Fixes #2134
## 3.0.0a3 27/12/2022
* Add web-ui v3.0.0a3

@ -247,6 +247,12 @@
}
],
"versions": [
{
"name": "5.3.1",
"images": {
"hda_disk_image": "cumulus-linux-5.3.1-vx-amd64-qemu.qcow2"
}
},
{
"name": "5.1.0",
"images": {

@ -0,0 +1,59 @@
{
"appliance_id": "f3b6a3ac-7be5-4bb0-b204-da3712fb646c",
"name": "Windows-11-Dev-Env",
"category": "guest",
"description": "Windows 11 Developer Environment Virtual Machine.",
"vendor_name": "Microsoft",
"vendor_url": "https://www.microsoft.com",
"documentation_url": "https://developer.microsoft.com/en-us/windows/downloads/virtual-machines/",
"product_name": "Windows 11 Development Environment",
"product_url": "https://developer.microsoft.com/en-us/windows/downloads/virtual-machines/",
"registry_version": 4,
"status": "experimental",
"availability": "free",
"maintainer": "Ean Towne",
"maintainer_email": "eantowne@gmail.com",
"usage": "Uses SPICE not VNC\nHighly recommended to install the SPICE-agent from: https://www.spice-space.org/download/windows/spice-guest-tools/spice-guest-tools-latest.exe to be able to change resolution and increase performance.\nThis is an evaluation virtual machine (90 days) and includes:\n* Window 11 Enterprise (Evaluation)\n* Visual Studio 2022 Community Edition with UWP .NET Desktop, Azure, and Windows App SDK for C# workloads enabled\n* Windows Subsystem for Linux 2 enabled with Ubuntu installed\n* Windows Terminal installed\n* Developer mode enabled",
"symbol": "microsoft.svg",
"first_port_name": "Network Adapter 1",
"port_name_format": "Network Adapter {0}",
"qemu": {
"adapter_type": "e1000",
"adapters": 1,
"ram": 4096,
"cpus": 4,
"hda_disk_interface": "sata",
"arch": "x86_64",
"console_type": "spice",
"boot_priority": "c",
"kvm": "require"
},
"images": [
{
"filename": "WinDev2212Eval-disk1.vmdk",
"version": "2212",
"md5sum": "c79f393a067b92e01a513a118d455ac8",
"filesize": 24620493824,
"download_url": "https://aka.ms/windev_VM_vmware",
"compression": "zip"
},
{
"filename": "OVMF-20160813.fd",
"version": "16.08.13",
"md5sum": "8ff0ef1ec56345db5b6bda1a8630e3c6",
"filesize": 2097152,
"download_url": "",
"direct_download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/OVMF-20160813.fd.zip/download",
"compression": "zip"
}
],
"versions": [
{
"images": {
"bios_image": "OVMF-20160813.fd",
"hda_disk_image": "WinDev2212Eval-disk1.vmdk"
},
"name": "2212"
}
]
}

@ -92,6 +92,8 @@ class BaseNode:
self._wrap_console = wrap_console
self._wrap_aux = wrap_aux
self._wrapper_telnet_servers = []
self._wrap_console_reader = None
self._wrap_console_writer = None
self._internal_console_port = None
self._internal_aux_port = None
self._custom_adapters = []
@ -375,7 +377,6 @@ class BaseNode:
if self._wrap_console:
self._manager.port_manager.release_tcp_port(self._internal_console_port, self._project)
self._internal_console_port = None
if self._aux:
self._manager.port_manager.release_tcp_port(self._aux, self._project)
self._aux = None
@ -415,15 +416,23 @@ class BaseNode:
remaining_trial = 60
while True:
try:
(reader, writer) = await asyncio.open_connection(host="127.0.0.1", port=internal_port)
(self._wrap_console_reader, self._wrap_console_writer) = await asyncio.open_connection(
host="127.0.0.1",
port=self._internal_console_port
)
break
except (OSError, ConnectionRefusedError) as e:
if remaining_trial <= 0:
raise e
await asyncio.sleep(0.1)
remaining_trial -= 1
await AsyncioTelnetServer.write_client_intro(writer, echo=True)
server = AsyncioTelnetServer(reader=reader, writer=writer, binary=True, echo=True)
await AsyncioTelnetServer.write_client_intro(self._wrap_console_writer, echo=True)
server = AsyncioTelnetServer(
reader=self._wrap_console_reader,
writer=self._wrap_console_writer,
binary=True,
echo=True
)
# warning: this will raise OSError exception if there is a problem...
telnet_server = await asyncio.start_server(server.run, self._manager.port_manager.console_host, external_port)
self._wrapper_telnet_servers.append(telnet_server)
@ -453,14 +462,17 @@ class BaseNode:
Stops the telnet proxy servers.
"""
if self._wrap_console_writer:
self._wrap_console_writer.close()
await self._wrap_console_writer.wait_closed()
for telnet_proxy_server in self._wrapper_telnet_servers:
telnet_proxy_server.close()
await telnet_proxy_server.wait_closed()
self._wrapper_telnet_servers = []
async def reset_console(self):
async def reset_wrap_console(self):
"""
Reset console
Reset the wrap console (restarts the Telnet proxy)
"""
await self.stop_wrap_console()

@ -87,5 +87,13 @@ done
ifup -a -f
# continue normal docker startup
eval HOME=$(echo ~${GNS3_USER-root})
case "$GNS3_USER" in
[1-9][0-9]*)
# for when the user field defined in the Docker container is an ID
export GNS3_USER=$(cat /etc/passwd | grep ${GNS3_USER-root} | awk -F: '{print $1}')
;;
*)
;;
esac
eval HOME="$(echo ~${GNS3_USER-root})"
exec su ${GNS3_USER-root} -p -- /gns3/run-cmd.sh "$OLD_PATH" "$@"

@ -278,9 +278,13 @@ class Dynamips(BaseManager):
if not working_dir:
working_dir = tempfile.gettempdir()
# FIXME: hypervisor should always listen to 127.0.0.1
# See https://github.com/GNS3/dynamips/issues/62
server_host = self.config.settings.Server.host
if not sys.platform.startswith("win"):
# Hypervisor should always listen to 127.0.0.1
# See https://github.com/GNS3/dynamips/issues/62
# This was fixed in Dynamips v0.2.23 which hasn't been built for Windows
server_host = "127.0.0.1"
else:
server_host = self.config.settings.Server.host
try:
info = socket.getaddrinfo(server_host, 0, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
@ -305,6 +309,8 @@ class Dynamips(BaseManager):
await hypervisor.connect()
if parse_version(hypervisor.version) < parse_version("0.2.11"):
raise DynamipsError(f"Dynamips version must be >= 0.2.11, detected version is {hypervisor.version}")
if not sys.platform.startswith("win") and parse_version(hypervisor.version) < parse_version('0.2.23'):
raise DynamipsError(f"Dynamips version must be >= 0.2.23 on Linux/macOS, detected version is {hypervisor.version}")
return hypervisor

@ -95,7 +95,9 @@ class DynamipsHypervisor:
try:
version = await self.send("hypervisor version")
self._version = version[0].split("-", 1)[0]
log.info("Dynamips version {} detected".format(self._version))
except IndexError:
log.warning("Dynamips version could not be detected")
self._version = "Unknown"
# this forces to send the working dir to Dynamips

@ -197,11 +197,9 @@ class Hypervisor(DynamipsHypervisor):
command = [self._path]
command.extend(["-N1"]) # use instance IDs for filenames
command.extend(["-l", f"dynamips_i{self._id}_log.txt"]) # log file
# Dynamips cannot listen for hypervisor commands and for console connections on
# 2 different IP addresses.
# See https://github.com/GNS3/dynamips/issues/62
if self._console_host != "0.0.0.0" and self._console_host != "::":
command.extend(["-H", f"{self._host}:{self._port}"])
if not sys.platform.startswith("win"):
command.extend(["-H", f"{self._host}:{self._port}", "--console-binding-addr", self._console_host])
else:
command.extend(["-H", str(self._port)])
return command

@ -1012,11 +1012,8 @@ class Router(BaseNode):
if self.console_type != console_type:
status = await self.get_status()
if status == "running":
raise DynamipsError(
'"{name}" must be stopped to change the console type to {console_type}'.format(
name=self._name, console_type=console_type
)
)
raise DynamipsError('"{name}" must be stopped to change the console type to {console_type}'.format(name=self._name,
console_type=console_type))
self.console_type = console_type
@ -1033,6 +1030,13 @@ class Router(BaseNode):
self.aux = aux
await self._hypervisor.send(f'vm set_aux_tcp_port "{self._name}" {aux}')
async def reset_console(self):
"""
Reset console
"""
pass # reset console is not supported with Dynamips
async def get_cpu_usage(self, cpu_id=0):
"""
Shows cpu usage in seconds, "cpu_id" is ignored.

@ -15,6 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import socket
import ipaddress
from fastapi import HTTPException, status
from gns3server.config import Config
@ -145,13 +146,19 @@ class PortManager:
@console_host.setter
def console_host(self, new_host):
"""
Bind console host to 0.0.0.0 if remote connections are allowed.
Bind console host to 0.0.0.0 or :: if remote connections are allowed.
"""
remote_console_connections = Config.instance().settings.Server.allow_remote_console
if remote_console_connections:
log.warning("Remote console connections are allowed")
self._console_host = "0.0.0.0"
try:
ip = ipaddress.ip_address(new_host)
if isinstance(ip, ipaddress.IPv6Address):
self._console_host = "::"
except ValueError:
log.warning("Could not determine IP address type for console host")
else:
self._console_host = new_host

@ -107,6 +107,7 @@ class QemuVM(BaseNode):
self._monitor_host = manager.config.settings.Qemu.monitor_host
self._process = None
self._cpulimit_process = None
self._swtpm_process = None
self._monitor = None
self._stdout_file = ""
self._qemu_img_stdout_file = ""
@ -151,6 +152,7 @@ class QemuVM(BaseNode):
self._kernel_image = ""
self._kernel_command_line = ""
self._replicate_network_connection_state = True
self._tpm = False
self._create_config_disk = False
self._on_close = "power_off"
self._cpu_throttling = 0 # means no CPU throttling
@ -728,7 +730,7 @@ class QemuVM(BaseNode):
"""
Sets whether a config disk is automatically created on HDD disk interface (secondary slave)
:param replicate_network_connection_state: boolean
:param create_config_disk: boolean
"""
if create_config_disk:
@ -874,6 +876,30 @@ class QemuVM(BaseNode):
log.info(f'QEMU VM "{self._name}" [{self._id}] has set maximum number of hotpluggable vCPUs to {maxcpus}')
self._maxcpus = maxcpus
@property
def tpm(self):
"""
Returns whether TPM is activated for this QEMU VM.
:returns: boolean
"""
return self._tpm
@tpm.setter
def tpm(self, tpm):
"""
Sets whether TPM is activated for this QEMU VM.
:param tpm: boolean
"""
if tpm:
log.info(f'QEMU VM "{self._name}" [{self._id}] has enabled the Trusted Platform Module (TPM)')
else:
log.info(f'QEMU VM "{self._name}" [{self._id}] has disabled the Trusted Platform Module (TPM)')
self._tpm = tpm
@property
def options(self):
"""
@ -1039,11 +1065,8 @@ class QemuVM(BaseNode):
"""
if self._cpulimit_process and self._cpulimit_process.returncode is None:
self._cpulimit_process.kill()
try:
self._process.wait(3)
except subprocess.TimeoutExpired:
log.error(f"Could not kill cpulimit process {self._cpulimit_process.pid}")
self._cpulimit_process.terminate()
self._cpulimit_process = None
def _set_cpu_throttling(self):
"""
@ -1054,10 +1077,13 @@ class QemuVM(BaseNode):
return
try:
subprocess.Popen(
["cpulimit", "--lazy", f"--pid={self._process.pid}", f"--limit={self._cpu_throttling}"],
cwd=self.working_dir,
)
if sys.platform.startswith("win") and hasattr(sys, "frozen"):
cpulimit_exec = os.path.join(os.path.dirname(os.path.abspath(sys.executable)), "cpulimit", "cpulimit.exe")
else:
cpulimit_exec = "cpulimit"
command = [cpulimit_exec, "--lazy", "--pid={}".format(self._process.pid), "--limit={}".format(self._cpu_throttling)]
self._cpulimit_process = subprocess.Popen(command, cwd=self.working_dir)
log.info(f"CPU throttled to {self._cpu_throttling}%")
except FileNotFoundError:
raise QemuError("cpulimit could not be found, please install it or deactivate CPU throttling")
@ -1134,7 +1160,8 @@ class QemuVM(BaseNode):
await self._set_process_priority()
if self._cpu_throttling:
self._set_cpu_throttling()
if self._tpm:
self._start_swtpm()
if "-enable-kvm" in command_string or "-enable-hax" in command_string:
self._hw_virtualization = True
@ -1219,6 +1246,7 @@ class QemuVM(BaseNode):
log.warning(f'QEMU VM "{self._name}" PID={self._process.pid} is still running')
self._process = None
self._stop_cpulimit()
self._stop_swtpm()
if self.on_close != "save_vm_state":
await self._clear_save_vm_stated()
await self._export_config()
@ -1746,6 +1774,14 @@ class QemuVM(BaseNode):
self._process = None
return False
async def reset_console(self):
"""
Reset console
"""
if self.is_running():
await self.reset_wrap_console()
def command(self):
"""
Returns the QEMU command line.
@ -2243,6 +2279,60 @@ class QemuVM(BaseNode):
return options
def _start_swtpm(self):
"""
Start swtpm (TPM emulator)
"""
if sys.platform.startswith("win"):
raise QemuError("swtpm (TPM emulator) is not supported on Windows")
tpm_dir = os.path.join(self.working_dir, "tpm")
os.makedirs(tpm_dir, exist_ok=True)
tpm_sock = os.path.join(self.temporary_directory, "swtpm.sock")
swtpm = shutil.which("swtpm")
if not swtpm:
raise QemuError("Could not find swtpm (TPM emulator)")
try:
command = [
swtpm,
"socket",
"--tpm2",
'--tpmstate', "dir={}".format(tpm_dir),
"--ctrl",
"type=unixio,path={},terminate".format(tpm_sock)
]
command_string = " ".join(shlex.quote(s) for s in command)
log.info("Starting swtpm (TPM emulator) with: {}".format(command_string))
self._swtpm_process = subprocess.Popen(command, cwd=self.working_dir)
log.info("swtpm (TPM emulator) has started")
except (OSError, subprocess.SubprocessError) as e:
raise QemuError("Could not start swtpm (TPM emulator): {}".format(e))
def _stop_swtpm(self):
"""
Stop swtpm (TPM emulator)
"""
if self._swtpm_process and self._swtpm_process.returncode is None:
self._swtpm_process.terminate()
self._swtpm_process = None
def _tpm_options(self):
"""
Return the TPM options for Qemu.
"""
tpm_sock = os.path.join(self.temporary_directory, "swtpm.sock")
options = [
"-chardev",
"socket,id=chrtpm,path={}".format(tpm_sock),
"-tpmdev",
"emulator,id=tpm0,chardev=chrtpm",
"-device",
"tpm-tis,tpmdev=tpm0"
]
return options
async def _network_options(self):
network_options = []
@ -2495,6 +2585,8 @@ class QemuVM(BaseNode):
command.extend(await self._saved_state_option())
if self._console_type == "telnet":
command.extend(await self._disable_graphics())
if self._tpm:
command.extend(self._tpm_options())
if additional_options:
try:
command.extend(shlex.split(additional_options))

@ -347,6 +347,14 @@ class VPCSVM(BaseNode):
return True
return False
async def reset_console(self):
"""
Reset console
"""
if self.is_running():
await self.reset_wrap_console()
@BaseNode.console_type.setter
def console_type(self, new_console_type):
"""

@ -18,13 +18,19 @@
import os
import sys
import uuid
import socket
import shutil
import asyncio
import random
import importlib_resources
try:
import importlib_resources
except ImportError:
from importlib import resources as importlib_resources
from ..config import Config
from ..utils import parse_version
from .project import Project
from .appliance import Appliance
from .appliance_manager import ApplianceManager
@ -62,7 +68,7 @@ class Controller:
async def start(self, computes=None):
log.info("Controller is starting")
self._load_base_files()
self._install_base_configs()
server_config = Config.instance().settings.Server
Config.instance().listen_for_config_changes(self._update_config)
name = server_config.name
@ -282,6 +288,10 @@ class Controller:
except OSError as e:
log.error(f"Cannot read Etag appliance file '{etag_appliances_path}': {e}")
# FIXME
#if parse_version(__version__) > parse_version(controller_settings.get("version", "")):
# self._appliance_manager.install_builtin_appliances()
self._appliance_manager.install_builtin_appliances()
self._appliance_manager.load_appliances()
self._config_loaded = True
@ -307,13 +317,14 @@ class Controller:
except OSError as e:
log.error(str(e))
def _load_base_files(self):
def _install_base_configs(self):
"""
At startup we copy base file to the user location to allow
them to customize it
"""
dst_path = self.configs_path()
log.info(f"Installing base configs in '{dst_path}'")
try:
if hasattr(sys, "frozen") and sys.platform.startswith("win"):
resource_path = os.path.normpath(os.path.join(os.path.dirname(sys.executable), "configs"))

@ -20,9 +20,14 @@ import os
import json
import asyncio
import aiofiles
import importlib_resources
import shutil
try:
import importlib_resources
except ImportError:
from importlib import resources as importlib_resources
from typing import Tuple, List
from aiohttp.client_exceptions import ClientError
@ -94,13 +99,15 @@ class ApplianceManager:
os.makedirs(appliances_path, exist_ok=True)
return appliances_path
def _builtin_appliances_path(self):
def _builtin_appliances_path(self, delete_first=False):
"""
Get the built-in appliance storage directory
"""
config = Config.instance()
appliances_dir = os.path.join(config.config_dir, "appliances")
if delete_first:
shutil.rmtree(appliances_dir, ignore_errors=True)
os.makedirs(appliances_dir, exist_ok=True)
return appliances_dir
@ -109,17 +116,17 @@ class ApplianceManager:
At startup we copy the built-in appliances files.
"""
dst_path = self._builtin_appliances_path()
dst_path = self._builtin_appliances_path(delete_first=True)
log.info(f"Installing built-in appliances in '{dst_path}'")
try:
if hasattr(sys, "frozen") and sys.platform.startswith("win"):
resource_path = os.path.normpath(os.path.join(os.path.dirname(sys.executable), "appliances"))
for filename in os.listdir(resource_path):
if not os.path.exists(os.path.join(dst_path, filename)):
shutil.copy(os.path.join(resource_path, filename), os.path.join(dst_path, filename))
shutil.copy(os.path.join(resource_path, filename), os.path.join(dst_path, filename))
else:
for entry in importlib_resources.files('gns3server.appliances').iterdir():
full_path = os.path.join(dst_path, entry.name)
if entry.is_file() and not os.path.exists(full_path):
if entry.is_file():
log.debug(f"Installing built-in appliance file {entry.name} to {full_path}")
shutil.copy(str(entry), os.path.join(dst_path, entry.name))
except OSError as e:

@ -39,7 +39,7 @@ log = logging.getLogger(__name__)
class Node:
# This properties are used only on controller and are not forwarded to the compute
# These properties are used only on controller and are not forwarded to the compute
CONTROLLER_ONLY_PROPERTIES = [
"x",
"y",

File diff suppressed because one or more lines are too long

@ -200,6 +200,7 @@ class AsyncioTelnetServer:
except ConnectionError:
async with self._lock:
network_writer.close()
await network_writer.wait_closed()
if self._reader_process == network_reader:
self._reader_process = None
# Cancel current read from this reader
@ -214,6 +215,8 @@ class AsyncioTelnetServer:
try:
writer.write_eof()
await writer.drain()
writer.close()
await writer.wait_closed()
except (AttributeError, ConnectionError):
continue

@ -18,7 +18,12 @@ import atexit
import logging
import os
import sys
import importlib_resources
try:
import importlib_resources
except ImportError:
from importlib import resources as importlib_resources
from contextlib import ExitStack
resource_manager = ExitStack()

@ -176,7 +176,7 @@ async def test_termination_callback(vm):
await vm._termination_callback(0)
assert vm.status == "stopped"
await queue.get(1) #  Ping
await queue.get(1) # Ping
(action, event, kwargs) = await queue.get(1)
assert action == "node.updated"
@ -405,6 +405,17 @@ async def test_spice_option(vm, fake_qemu_img_binary):
assert '-vga qxl' in ' '.join(options)
@pytest.mark.asyncio
async def test_tpm_option(vm, tmpdir, fake_qemu_img_binary):
vm.manager.get_qemu_version = AsyncioMagicMock(return_value="3.1.0")
vm._tpm = True
tpm_sock = os.path.join(vm.temporary_directory, "swtpm.sock")
options = await vm._build_command()
assert '-chardev socket,id=chrtpm,path={}'.format(tpm_sock) in ' '.join(options)
assert '-tpmdev emulator,id=tpm0,chardev=chrtpm' in ' '.join(options)
assert '-device tpm-tis,tpmdev=tpm0' in ' '.join(options)
@pytest.mark.asyncio
async def test_disk_options_multiple_disk(vm, tmpdir, fake_qemu_img_binary):

@ -350,13 +350,13 @@ async def test_get_free_project_name(controller):
@pytest.mark.asyncio
async def test_load_base_files(controller, config, tmpdir):
async def test_install_base_configs(controller, config, tmpdir):
config.settings.Server.configs_path = str(tmpdir)
with open(str(tmpdir / 'iou_l2_base_startup-config.txt'), 'w+') as f:
f.write('test')
controller._load_base_files()
controller._install_base_configs()
assert os.path.exists(str(tmpdir / 'iou_l3_base_startup-config.txt'))
# Check is the file has not been overwritten
@ -379,9 +379,10 @@ def test_appliances(controller, config, tmpdir):
with open(str(tmpdir / "my_appliance2.gns3a"), 'w+') as f:
json.dump(my_appliance, f)
config.settings.Server.appliances_path = str(tmpdir)
#config.settings.Server.appliances_path = str(tmpdir)
controller.appliance_manager.install_builtin_appliances()
controller.appliance_manager.load_appliances()
with patch("gns3server.config.Config.get_section_config", return_value={"appliances_path": str(tmpdir)}):
controller.appliance_manager.load_appliances()
assert len(controller.appliance_manager.appliances) > 0
for appliance in controller.appliance_manager.appliances.values():
assert appliance.asdict()["status"] != "broken"

Loading…
Cancel
Save