diff --git a/CHANGELOG b/CHANGELOG
index 8f75b1dfa..cde9da671 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,15 @@
# Change Log
+## 2.2.57 23/03/2026
+
+* feat(telnet_server): improve error handling and connection management
+* Deactivate 'use default IOU values' by default and update RAM/NVRAM values
+* Make sure the node shows as stopped when the wrap console cannot be stopped
+* tests(controller,port): short_name finishes successfully when port name is None
+* fix(controller,port): handle None port name to prevent TypeError in short_name method
+* fix: busybox static link detection on Alpine/musl
+* Take populated disks into consideration when calculating PCI device ID
+
## 2.2.56.1 28/01/2026
* Fix telnet keepalive options on macOS
diff --git a/gns3server/api/routes/controller/__init__.py b/gns3server/api/routes/controller/__init__.py
index 3345a654c..19edcbfb8 100644
--- a/gns3server/api/routes/controller/__init__.py
+++ b/gns3server/api/routes/controller/__init__.py
@@ -171,11 +171,13 @@ router.include_router(
router.include_router(
_llm_router,
prefix="/access",
+ dependencies=[Depends(get_current_active_user)],
tags=["LLM Model Configurations"]
)
router.include_router(
_chat_router,
prefix="/projects/{project_id}/chat",
+ dependencies=[Depends(get_current_active_user)],
tags=["Chat"]
)
diff --git a/gns3server/api/routes/controller/appliances.py b/gns3server/api/routes/controller/appliances.py
index 93db5b204..1ca32d8e9 100644
--- a/gns3server/api/routes/controller/appliances.py
+++ b/gns3server/api/routes/controller/appliances.py
@@ -21,7 +21,7 @@ API routes for appliances.
import logging
from fastapi import APIRouter, Depends, status
-from typing import Optional, List
+from typing import Optional, List, Union
from uuid import UUID
from gns3server import schemas
@@ -94,7 +94,7 @@ def get_appliance(appliance_id: UUID) -> schemas.Appliance:
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Appliance.Allocate"))]
)
-def add_appliance_version(appliance_id: UUID, appliance_version: schemas.ApplianceVersion) -> dict:
+def add_appliance_version(appliance_id: UUID, appliance_version: Union[schemas.ApplianceVersion, schemas.ApplianceVersionV8]) -> dict:
"""
Add a version to an appliance.
diff --git a/gns3server/api/routes/controller/dependencies/authentication.py b/gns3server/api/routes/controller/dependencies/authentication.py
index 05e4d4ae7..fb21aa8e3 100644
--- a/gns3server/api/routes/controller/dependencies/authentication.py
+++ b/gns3server/api/routes/controller/dependencies/authentication.py
@@ -47,14 +47,20 @@ async def get_user_from_token(
headers={"WWW-Authenticate": "Bearer"},
)
- username = auth_service.get_username_from_token(token)
- user = await user_repo.get_user_by_username(username)
+ token_data = auth_service.get_token_data(token)
+ user = await user_repo.get_user_by_username(token_data.username)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
+ if token_data.token_version != user.token_version:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=f"Token has been revoked for '{token_data.username}'",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
return user
@@ -87,13 +93,18 @@ async def get_current_active_user_from_websocket(
await websocket.accept()
try:
- username = auth_service.get_username_from_token(token)
- user = await user_repo.get_user_by_username(username)
+ token_data = auth_service.get_token_data(token)
+ user = await user_repo.get_user_by_username(token_data.username)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
- detail=f"Could not validate credentials for '{username}'"
+ detail=f"Could not validate credentials for '{token_data.username}'"
+ )
+ if token_data.token_version != user.token_version:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=f"Token has been revoked for '{token_data.username}'"
)
# Super admin is always authorized
@@ -103,7 +114,7 @@ async def get_current_active_user_from_websocket(
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
- detail=f"'{username}' is not an active user"
+ detail=f"'{token_data.username}' is not an active user"
)
return user
diff --git a/gns3server/api/routes/controller/users.py b/gns3server/api/routes/controller/users.py
index 0bd6373d6..1a19da98f 100644
--- a/gns3server/api/routes/controller/users.py
+++ b/gns3server/api/routes/controller/users.py
@@ -65,7 +65,10 @@ async def login(
headers={"WWW-Authenticate": "Bearer"},
)
- token = schemas.Token(access_token=auth_service.create_access_token(user.username), token_type="bearer")
+ token = schemas.Token(
+ access_token=auth_service.create_access_token(user.username, token_version=user.token_version),
+ token_type="bearer"
+ )
return token
@@ -87,10 +90,25 @@ async def authenticate(
headers={"WWW-Authenticate": "Bearer"},
)
- token = schemas.Token(access_token=auth_service.create_access_token(user.username), token_type="bearer")
+ token = schemas.Token(
+ access_token=auth_service.create_access_token(user.username, token_version=user.token_version),
+ token_type="bearer"
+ )
return token
+@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
+async def logout(
+ current_user: schemas.User = Depends(get_current_active_user),
+ users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
+) -> None:
+ """
+ Logout the current user by revoking all existing tokens.
+ """
+
+ await users_repo.logout_user(current_user.user_id)
+
+
@router.get("/me", response_model=schemas.User)
async def get_logged_in_user(current_user: schemas.User = Depends(get_current_active_user)) -> schemas.User:
"""
diff --git a/gns3server/appliances/alpinet.gns3a b/gns3server/appliances/alpinet.gns3a
new file mode 100644
index 000000000..7a7ecacbe
--- /dev/null
+++ b/gns3server/appliances/alpinet.gns3a
@@ -0,0 +1,21 @@
+{
+ "appliance_id": "a2209414-88a6-403b-a2e1-477a057ec954",
+ "name": "AlpiNet",
+ "category": "guest",
+ "description": "AlpiNet is a lightweight Alpine-based networking toolbox for GNS3.\n\nCOMPREHENSIVE TOOLKIT:\n• Network: ip, ifconfig, ping, traceroute, mtr, arping, nmap, tcpdump\n• Performance: iperf, iperf3\n• HTTP/Web: curl, wget\n• TCP/UDP: netcat, socat, telnet\n• Advanced: ethtool, bridge-utils, vlan\n• Firewall: iptables, ip6tables, nftables\n• File Transfer: FTP (lftp), TFTP, SSH (scp/sftp), rsync\n• DNS: host, nslookup, dig\n• System: nano, tmux, screen, htop, bash\n• Text Tools: grep, sed, awk, jq, less\n• Compression: tar, gzip\n• Utilities: tree, file, openssl\n\nType 'alpinet-tools' in the container for the complete tool list.\n\nOptimized for network testing, troubleshooting, and education.",
+ "vendor_name": "AlpiNet Project",
+ "vendor_url": "https://www.alpinelinux.org/",
+ "product_name": "AlpiNet",
+ "registry_version": 4,
+ "status": "stable",
+ "maintainer": "nazDridoy",
+ "maintainer_email": "nazdridoy399@gmail.com",
+ "usage": "The /root directory is persistent across restarts. Type 'alpinet-tools' to see all available utilities.",
+ "symbol": "AlpiNet.svg",
+ "docker": {
+ "adapters": 1,
+ "image": "gns3/alpinet:latest",
+ "console_type": "telnet",
+ "environment": "TERM=xterm"
+ }
+}
diff --git a/gns3server/appliances/arista-veos.gns3a b/gns3server/appliances/arista-veos.gns3a
index 835556089..007a4bb7a 100644
--- a/gns3server/appliances/arista-veos.gns3a
+++ b/gns3server/appliances/arista-veos.gns3a
@@ -28,6 +28,13 @@
"options": "-cpu host"
},
"images": [
+ {
+ "filename": "vEOS64-lab-4.35.3F.qcow2",
+ "version": "4.35.3F",
+ "md5sum": "00d11e33dc4288f509441a4ab6319ca0",
+ "filesize": 638910464,
+ "download_url": "https://www.arista.com/en/support/software-download"
+ },
{
"filename": "vEOS64-lab-4.33.2F.qcow2",
"version": "4.33.2F",
@@ -58,6 +65,13 @@
}
],
"versions": [
+ {
+ "name": "4.35.3F",
+ "images": {
+ "hda_disk_image": "Aboot-veos-serial-8.0.2.iso",
+ "hdb_disk_image": "vEOS64-lab-4.35.3F.qcow2"
+ }
+ },
{
"name": "4.33.2F",
"images": {
diff --git a/gns3server/appliances/asterfusion-vAsterNOS-VPP.gns3a b/gns3server/appliances/asterfusion-vAsterNOS-VPP.gns3a
index 5c467f5dc..86016b329 100644
--- a/gns3server/appliances/asterfusion-vAsterNOS-VPP.gns3a
+++ b/gns3server/appliances/asterfusion-vAsterNOS-VPP.gns3a
@@ -2,7 +2,7 @@
"appliance_id": "4aa78f74-c769-43e0-9ef1-5dd2cf1909c6",
"name": "Asterfusion AsterNOS-VPP",
"category": "router",
- "description": "AsterNOS-VPP leverages SONiC’s robust routing features and management capability with VPP’s high-performance data forwarding. It supports L3 routing, NAT, PPPoE, and VPN services. Minimum requirements: 4 vCPUs and 4GB RAM.",
+ "description": "AsterNOS-VPP leverages SONiC’s robust routing features and management capability with VPP’s high-performance data forwarding. It supports L3 routing, NAT, PPPoE, and VPN services. Minimum requirements: 4 vCPUs and 8GB RAM.",
"vendor_name": "Asterfusion",
"vendor_url": "https://cloudswit.ch/product/sonic-enterprise-distribution/#vAsterNOS",
"documentation_url": "https://docs.asternos.com/routing",
@@ -11,23 +11,32 @@
"status": "stable",
"maintainer": "Asterfusion Product Team",
"maintainer_email": "bd@cloudswit.ch",
+ "availability": "with-registration",
"images": [
{
- "filename": "AsterNOS-VPP_V6.1-R0101P02_x86.img.gz",
- "version": "V6.1",
+ "filename": "AsterNOS-VPP_V6.1-R0101P02_x86.img",
+ "version": "6.1",
"md5sum": "55834c3e8849ab226144d79ef76acf81",
"filesize": 1318201015,
"download_url": "https://asternos.dev/api/file/b2c9fd83-a9d3-46f3-9676-6b49dd481d6c",
- "comments": "Registration is required to download the image."
+ "compression": "gzip"
}
],
"qemu": {
"adapter_type": "virtio-net-pci",
"adapters": 4,
- "ram": 4096,
+ "ram": 8192,
"cpus": 4,
"arch": "x86_64",
"console_type": "telnet",
"kvm": "require"
- }
+ },
+ "versions": [
+ {
+ "name": "6.1",
+ "images": {
+ "hda_disk_image": "AsterNOS-VPP_V6.1-R0101P02_x86.img"
+ }
+ }
+ ]
}
diff --git a/gns3server/appliances/cisco-iou-l2.gns3a b/gns3server/appliances/cisco-iou-l2.gns3a
index 0f6613ac3..daf98d7da 100644
--- a/gns3server/appliances/cisco-iou-l2.gns3a
+++ b/gns3server/appliances/cisco-iou-l2.gns3a
@@ -15,11 +15,17 @@
"iou": {
"ethernet_adapters": 4,
"serial_adapters": 0,
- "nvram": 512,
- "ram": 512,
+ "nvram": 256,
+ "ram": 1024,
"startup_config": "iou_l2_base_startup-config.txt"
},
"images": [
+ {
+ "filename": "x86_64_crb_linux_l2-adventerprisek9-ms.17.16.1a.iol",
+ "version": "17.16.1a",
+ "md5sum": "d55879318e51c5401fb9314d45c67728",
+ "filesize": 244265240
+ },
{
"filename": "x86_64_crb_linux_l2-adventerprisek9-ms.iol",
"version": "17.15.1",
@@ -33,25 +39,25 @@
"filesize": 240355720
},
{
- "filename": "i86bi-linux-l2-ipbasek9-15.1g.bin",
- "version": "15.1g",
- "md5sum": "0b8b9e14ca99b68c654e44c4296857ba",
- "filesize": 62137336
+ "filename": "i86bi_linux_l2-adventerprisek9-ms.SSA.high_iron_20190423.bin",
+ "version": "15.2(20190423)",
+ "md5sum": "ffe884f8e762b762d7886c51d8ee015c",
+ "filesize": 126249700
},
{
- "filename": "i86bi-linux-l2-adventerprisek9-15.1a.bin",
- "version": "15.1a",
- "md5sum": "9549a20a7391fb849da32caa77a0d254",
- "filesize": 72726092
- },
- {
- "filename": "i86bi-linux-l2-adventerprisek9-15.2d.bin",
- "version": "15.2d",
- "md5sum": "f16db44433beb3e8c828db5ddad1de8a",
- "filesize": 105036380
+ "filename": "i86bi_LinuxL2-AdvEnterpriseK9-M_152_May_2018.bin",
+ "version": "15.2(20180510)",
+ "md5sum": "d704b68c5f4c4f92e56b754b49b92012",
+ "filesize": 126226692
}
],
"versions": [
+ {
+ "name": "17.16.1a",
+ "images": {
+ "image": "x86_64_crb_linux_l2-adventerprisek9-ms.17.16.1a.iol"
+ }
+ },
{
"name": "17.15.1",
"images": {
@@ -65,21 +71,15 @@
}
},
{
- "name": "15.1g",
+ "name": "15.2(20190423)",
"images": {
- "image": "i86bi-linux-l2-ipbasek9-15.1g.bin"
+ "image": "i86bi_linux_l2-adventerprisek9-ms.SSA.high_iron_20190423.bin"
}
},
{
- "name": "15.1a",
+ "name": "15.2(20180510)",
"images": {
- "image": "i86bi-linux-l2-adventerprisek9-15.1a.bin"
- }
- },
- {
- "name": "15.2d",
- "images": {
- "image": "i86bi-linux-l2-adventerprisek9-15.2d.bin"
+ "image": "i86bi_LinuxL2-AdvEnterpriseK9-M_152_May_2018.bin"
}
}
]
diff --git a/gns3server/appliances/cisco-iou-l3.gns3a b/gns3server/appliances/cisco-iou-l3.gns3a
index 262019098..06935482a 100644
--- a/gns3server/appliances/cisco-iou-l3.gns3a
+++ b/gns3server/appliances/cisco-iou-l3.gns3a
@@ -15,11 +15,17 @@
"iou": {
"ethernet_adapters": 2,
"serial_adapters": 2,
- "nvram": 512,
- "ram": 512,
+ "nvram": 256,
+ "ram": 1024,
"startup_config": "iou_l3_base_startup-config.txt"
},
"images": [
+ {
+ "filename": "x86_64_crb_linux-adventerprisek9-ms.17.16.1a.iol",
+ "version": "17.16.1a",
+ "md5sum": "39bdf959c3b99a3c81d66451f1cf0404",
+ "filesize": 293062920
+ },
{
"filename": "x86_64_crb_linux-adventerprisek9-ms.iol",
"version": "17.15.1",
@@ -37,21 +43,15 @@
"version": "15.7(3)M2",
"md5sum": "d6874260c3daeeb96d10fc844ae0b93b",
"filesize": 184759244
- },
- {
- "filename": "i86bi-linux-l3-adventerprisek9-ms.155-2.T.bin",
- "version": "155-2T",
- "md5sum": "45e99761a95cbd3ee3924ecf0f3d89e5",
- "filesize": 172982492
- },
- {
- "filename": "i86bi-linux-l3-adventerprisek9-15.4.1T.bin",
- "version": "15.4.1T",
- "md5sum": "2eabae17778316c49cbc80e8e81262f9",
- "filesize": 152677848
}
],
"versions": [
+ {
+ "name": "17.16.1a",
+ "images": {
+ "image": "x86_64_crb_linux-adventerprisek9-ms.17.16.1a.iol"
+ }
+ },
{
"name": "17.15.1",
"images": {
@@ -69,18 +69,6 @@
"images": {
"image": "i86bi_LinuxL3-AdvEnterpriseK9-M2_157_3_May_2018.bin"
}
- },
- {
- "name": "155-2T",
- "images": {
- "image": "i86bi-linux-l3-adventerprisek9-ms.155-2.T.bin"
- }
- },
- {
- "name": "15.4.1T",
- "images": {
- "image": "i86bi-linux-l3-adventerprisek9-15.4.1T.bin"
- }
}
]
}
diff --git a/gns3server/appliances/exos.gns3a b/gns3server/appliances/exos.gns3a
index 2393b3e09..00e3dd5e3 100644
--- a/gns3server/appliances/exos.gns3a
+++ b/gns3server/appliances/exos.gns3a
@@ -7,7 +7,7 @@
"vendor_url": "https://www.extremenetworks.com",
"documentation_url": "https://www.extremenetworks.com/support/documentation",
"product_name": "EXOS VM",
- "registry_version": 4,
+ "registry_version": 5,
"status": "stable",
"maintainer": "Extreme Networks",
"maintainer_email": "GitHubscripting@extremenetworks.com",
@@ -18,18 +18,25 @@
"qemu": {
"adapter_type": "rtl8139",
"adapters": 13,
- "ram": 512,
+ "ram": 1024,
"hda_disk_interface": "ide",
"arch": "x86_64",
"console_type": "telnet",
"boot_priority": "dc",
"kvm": "allow",
- "options": "-cpu core2duo"
+ "options": "-nographic -cpu host -enable-kvm"
},
"images": [
- {
+ {
+ "filename": "EXOS-VM_33.6.1.14.qcow2",
+ "version": "33.6.1.14",
+ "md5sum": "01ea4bea3f321f91910565bb223a2540",
+ "filesize": 278647296,
+ "direct_download_url": "https://akamai-ep.extremenetworks.com/Extreme_P/github-en/Virtual_EXOS/EXOS-VM_33.6.1.14.qcow2"
+ },
+ {
"filename": "EXOS-VM_32.7.2.19.qcow2",
"version": "32.7.2.19",
"md5sum": "eba580a2e18d2a9cc972c9ece8917ea8",
@@ -48,6 +55,12 @@
"versions": [
+ {
+ "name": "33.6.1.14",
+ "images": {
+ "hda_disk_image": "EXOS-VM_33.6.1.14.qcow2"
+ }
+ },
{
"name": "32.7.2.19",
"images": {
diff --git a/gns3server/appliances/home-assistant.gns3a b/gns3server/appliances/home-assistant.gns3a
new file mode 100644
index 000000000..d6cb3a5ae
--- /dev/null
+++ b/gns3server/appliances/home-assistant.gns3a
@@ -0,0 +1,60 @@
+{
+ "appliance_id": "4ac0d5bc-714d-4a5a-8af5-8fb452046acd",
+ "name": "Home Assistant",
+ "category": "guest",
+ "description": "Open source home automation that puts local control and privacy first. Powered by a worldwide community of tinkerers and DIY enthusiasts.",
+ "vendor_name": "Open Home Foundation",
+ "vendor_url": "https://www.openhomefoundation.org/",
+ "vendor_logo_url": "https://www.openhomefoundation.org/assets/images/logo/open-home-foundation.svg",
+ "documentation_url": "https://www.home-assistant.io/installation/alternative",
+ "product_name": "haos",
+ "product_url": "https://www.home-assistant.io/",
+ "registry_version": 8,
+ "status": "stable",
+ "availability": "free",
+ "maintainer": "Neyder Achahuanco Apaza",
+ "maintainer_email": "gns3@neyder.net",
+ "usage": "You will be able to reach Home Assistant on homeassistant.local:8123, you might need to access Home Assistant at homeassistant:8123 or http://X.X.X.X:8123 (replace X.X.X.X with your virtual machine\u2019s IP address).\nYou can access trough console with root user without password.",
+ "symbol": "home-assistant-logomark-color-on-light.svg",
+ "default_username": "cisco",
+ "default_password": "admin",
+ "settings": [
+ {
+ "name": "Default template settings",
+ "default": true,
+ "template_type": "qemu",
+ "template_properties":
+ {
+ "adapter_type": "virtio-net-pci",
+ "platform": "x86_64",
+ "adapters": 1,
+ "ram": 2048,
+ "cpus": 2,
+ "hda_disk_interface": "virtio",
+ "console_type": "telnet",
+ "boot_priority": "cd",
+ "on_close": "shutdown_signal",
+ "uefi": true
+ }
+ }
+ ],
+ "images": [
+ {
+ "filename": "haos_ova-17.1.qcow2",
+ "version": "17.1",
+ "md5sum": "e1b950430045859a3ac035cb420df158",
+ "filesize": 1024393216,
+ "download_url": "https://www.home-assistant.io/installation/alternative",
+ "direct_download_url": "https://github.com/home-assistant/operating-system/releases/download/17.1/haos_ova-17.1.qcow2.xz",
+ "compression": "xz"
+ }
+ ],
+ "versions": [
+ {
+ "images": {
+ "hda_disk_image": "haos_ova-17.1.qcow2"
+ },
+ "name": "17.1"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/gns3server/appliances/infix.gns3a b/gns3server/appliances/infix.gns3a
index d9ab0321f..f28a3d6a7 100644
--- a/gns3server/appliances/infix.gns3a
+++ b/gns3server/appliances/infix.gns3a
@@ -111,9 +111,37 @@
"md5sum": "e2eb1a4fff9d56815f37c2e82a6279fb",
"version": "25.11.0",
"direct_download_url": "https://github.com/kernelkit/infix/releases/download/v25.11.0/infix-x86_64-disk-25.11.0.qcow2"
+ },
+ {
+ "filename": "infix-x86_64-v26.01.0.qcow2",
+ "filesize": 307363840,
+ "md5sum": "bd3b9c56f98ba264c36e4d6f69931546",
+ "version": "26.01.0",
+ "direct_download_url": "https://github.com/kernelkit/infix/releases/download/v26.01.0/infix-x86_64-v26.01.0.qcow2"
+ },
+ {
+ "filename": "infix-x86_64-v26.02.1.qcow2",
+ "filesize": 316407808,
+ "md5sum": "0c4da508cfcb702c066f47227644b65f",
+ "version": "26.02.1",
+ "direct_download_url": "https://github.com/kernelkit/infix/releases/download/v26.02.1/infix-x86_64-v26.02.1.qcow2"
}
],
"versions": [
+ {
+ "name": "26.02.1",
+ "images": {
+ "bios_image": "OVMF-edk2-stable202305.fd",
+ "hda_disk_image": "infix-x86_64-v26.02.1.qcow2"
+ }
+ },
+ {
+ "name": "26.01.0",
+ "images": {
+ "bios_image": "OVMF-edk2-stable202305.fd",
+ "hda_disk_image": "infix-x86_64-v26.01.0.qcow2"
+ }
+ },
{
"name": "25.11.0",
"images": {
diff --git a/gns3server/appliances/juniper-vJunos-router.gns3a b/gns3server/appliances/juniper-vJunos-router.gns3a
index 9669079ac..8fecd0681 100644
--- a/gns3server/appliances/juniper-vJunos-router.gns3a
+++ b/gns3server/appliances/juniper-vJunos-router.gns3a
@@ -14,11 +14,11 @@
"maintainer_email": "github@sugarpapa.mozmail.com",
"usage": "GNS3 SHOULD be a baremetal installation. Using the GNS3 VM MIGHT result in unwanted issues. Default user is root. No password is needed.",
"symbol": "juniper-vmx.svg",
- "first_port_name": "ge-0/0/0",
+ "first_port_name": "fxp0",
"port_name_format": "ge-0/0/{port0}",
"qemu": {
"adapter_type": "virtio-net-pci",
- "adapters": 17,
+ "adapters": 10,
"ram": 5120,
"cpus": 4,
"hda_disk_interface": "virtio",
@@ -72,4 +72,4 @@
"name": "23.2R1.15"
}
]
-}
\ No newline at end of file
+}
diff --git a/gns3server/appliances/openwrt.gns3a b/gns3server/appliances/openwrt.gns3a
index ac3711d3a..84e9e6b57 100644
--- a/gns3server/appliances/openwrt.gns3a
+++ b/gns3server/appliances/openwrt.gns3a
@@ -23,6 +23,15 @@
"kvm": "allow"
},
"images": [
+ {
+ "filename": "openwrt-25.12.0-x86-64-generic-ext4-combined.img",
+ "version": "25.12.0",
+ "md5sum": "8c0e01307fc47c1f862a5d0c9a51c2c5",
+ "filesize": 126353408,
+ "download_url": "https://downloads.openwrt.org/releases/25.12.0/targets/x86/64/",
+ "direct_download_url": "https://downloads.openwrt.org/releases/25.12.0/targets/x86/64/openwrt-25.12.0-x86-64-generic-ext4-combined.img.gz",
+ "compression": "gzip"
+ },
{
"filename": "openwrt-24.10.2-x86-64-generic-ext4-combined.img",
"version": "24.10.2",
@@ -232,6 +241,12 @@
}
],
"versions": [
+ {
+ "name": "25.12.0",
+ "images": {
+ "hda_disk_image": "openwrt-25.12.0-x86-64-generic-ext4-combined.img"
+ }
+ },
{
"name": "24.10.2",
"images": {
diff --git a/gns3server/appliances/opnsense.gns3a b/gns3server/appliances/opnsense.gns3a
index 433b4f2de..7d3756052 100644
--- a/gns3server/appliances/opnsense.gns3a
+++ b/gns3server/appliances/opnsense.gns3a
@@ -25,6 +25,15 @@
"kvm": "require"
},
"images": [
+ {
+ "filename": "OPNsense-26.1-nano-amd64.img",
+ "version": "26.1",
+ "md5sum": "3b774e8fb651a5d00aa6fa3e0d1b1ff0",
+ "filesize": 3221225472,
+ "download_url": "https://opnsense.c0urier.net/releases/26.1/",
+ "direct_download_url": "https://opnsense.c0urier.net/releases/26.1/OPNsense-26.1-nano-amd64.img.bz2",
+ "compression": "bzip2"
+ },
{
"filename": "OPNsense-25.7-nano-amd64.img",
"version": "25.7",
@@ -90,6 +99,24 @@
}
],
"versions": [
+ {
+ "name": "26.1",
+ "images": {
+ "hda_disk_image": "OPNsense-26.1-nano-amd64.img"
+ }
+ },
+ {
+ "name": "25.7",
+ "images": {
+ "hda_disk_image": "OPNsense-25.7-nano-amd64.img"
+ }
+ },
+ {
+ "name": "25.1",
+ "images": {
+ "hda_disk_image": "OPNsense-25.1-nano-amd64.img"
+ }
+ },
{
"name": "24.7",
"images": {
diff --git a/gns3server/appliances/rhel.gns3a b/gns3server/appliances/rhel.gns3a
index c383c4b4d..8ee8916a6 100644
--- a/gns3server/appliances/rhel.gns3a
+++ b/gns3server/appliances/rhel.gns3a
@@ -13,11 +13,11 @@
"availability": "service-contract",
"maintainer": "Da-Geek",
"maintainer_email": "dageek@dageeks-geeks.gg",
- "usage": "You should download Red Hat Enterprise Linux KVM Guest Image from https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.5/x86_64/product-software attach/customize rhel-cloud-init.iso and start.\nusername: cloud-user\npassword: redhat",
+ "usage": "You should download Red Hat Enterprise Linux KVM Guest Image from https://access.redhat.com/downloads/content/rhel attach/customize rhel-cloud-init.iso and start.\nusername: cloud-user\npassword: redhat",
"qemu": {
"adapter_type": "virtio-net-pci",
"adapters": 1,
- "ram": 1024,
+ "ram": 1536,
"hda_disk_interface": "virtio",
"arch": "x86_64",
"console_type": "telnet",
@@ -27,11 +27,32 @@
},
"images": [
{
- "filename": "rhel-9.5-x86_64-kvm.qcow2",
- "version": "9.5",
- "md5sum": "8174396d5cb47727c59dd04dd9a05418",
- "filesize": 974389248,
- "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.5/x86_64/product-software"
+ "filename": "rhel-10.1-x86_64-kvm.qcow2",
+ "version": "10.1",
+ "md5sum": "b2f35b1ac0600de31cadf30a622a2fef",
+ "filesize": 1010827264,
+ "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---10/10.1/x86_64/product-software"
+ },
+ {
+ "filename": "rhel-10.0-x86_64-kvm.qcow2",
+ "version": "10.0",
+ "md5sum": "ea5918fa116ec53f9ef1ce4be69ae2be",
+ "filesize": 855506944,
+ "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---10/10.0/x86_64/product-software"
+ },
+ {
+ "filename": "rhel-9.7-x86_64-kvm.qcow2",
+ "version": "9.7",
+ "md5sum": "abc2f42cc5daeea6b03d0d2d478a0d1c",
+ "filesize": 1229979648,
+ "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.7/x86_64/product-software"
+ },
+ {
+ "filename": "rhel-9.6-x86_64-kvm.qcow2",
+ "version": "9.6",
+ "md5sum": "056648d716275053ebe77b1d4f68c862",
+ "filesize": 1060438016,
+ "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.6/x86_64/product-software"
},
{
"filename": "rhel-9.4-x86_64-kvm.qcow2",
@@ -40,34 +61,6 @@
"filesize": 957218816,
"download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.4/x86_64/product-software"
},
- {
- "filename": "rhel-9.3-x86_64-kvm.qcow2",
- "version": "9.3",
- "md5sum": "409d8d15f5177db2617b0e3e02139b5c",
- "filesize": 858193920,
- "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.3/x86_64/product-software"
- },
- {
- "filename": "rhel-9.2-x86_64-kvm.qcow2",
- "version": "9.2",
- "md5sum": "f33845298b387dbcfbf162c6b4e3f8c8",
- "filesize": 819265536,
- "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/9.2/x86_64/product-software"
- },
- {
- "filename": "rhel-baseos-9.1-x86_64-kvm.qcow2",
- "version": "9.1",
- "md5sum": "622de743da83bcec1ad2959ecaedb8f4",
- "filesize": 753401856,
- "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/9.1/x86_64/product-software"
- },
- {
- "filename": "rhel-baseos-9.0-x86_64-kvm.qcow2",
- "version": "9.0",
- "md5sum": "4a41497d354fe99a4abf55f1ed73edcb",
- "filesize": 696582144,
- "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/9.0/x86_64/product-software"
- },
{
"filename": "rhel-8.10-x86_64-kvm.qcow2",
"version": "8.10",
@@ -75,13 +68,6 @@
"filesize": 1065091072,
"download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.10/x86_64/product-software"
},
- {
- "filename": "rhel-8.9-x86_64-kvm.qcow2",
- "version": "8.9",
- "md5sum": "23295fe508678cbdebfbdbd41ef6e6e2",
- "filesize": 971833344,
- "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.9/x86_64/product-software"
- },
{
"filename": "rhel-8.8-x86_64-kvm.qcow2",
"version": "8.8",
@@ -89,13 +75,6 @@
"filesize": 926810112,
"download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.8/x86_64/product-software"
},
- {
- "filename": "rhel-8.7-x86_64-kvm.qcow2",
- "version": "8.7",
- "md5sum": "ab71a2c4cc276441bf999f531e064507",
- "filesize": 858128384,
- "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.7/x86_64/product-software"
- },
{
"filename": "rhel-8.6-x86_64-kvm.qcow2",
"version": "8.6",
@@ -104,31 +83,10 @@
"download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.6/x86_64/product-software"
},
{
- "filename": "rhel-8.5-x86_64-kvm.qcow2",
- "version": "8.5",
- "md5sum": "1efb78dbb2033ba4ac6589a06c95c2d4",
- "filesize": 779419648,
- "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.5/x86_64/product-software"
- },
- {
- "filename": "rhel-8.4-x86_64-kvm.qcow2",
- "version": "8.4",
- "md5sum": "db4c3a72857b784dc6e96120351f2894",
- "filesize": 727449600,
- "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.4/x86_64/product-software"
- },
- {
- "filename": "rhel-8.3-x86_64-kvm.qcow2",
- "version": "8.3",
- "md5sum": "dd554c059e0910379fff88f677f4a4b3",
- "filesize": 1316683776,
- "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.3/x86_64/product-software"
- },
- {
- "filename": "rhel-server-7.9-x86_64-kvm.qcow2",
+ "filename": "rhel-server-7.9-update-12-x86_64-kvm.qcow2",
"version": "7.9",
- "md5sum": "8d6669b3e2bb8df15b9b4280936cf950",
- "filesize": 827777024,
+ "md5sum": "f77fc7e3cf31a210a8244e486466ce34",
+ "filesize": 838036992,
"download_url": "https://access.redhat.com/downloads/content/69/ver=/rhel---7/7.9/x86_64/product-software"
},
{
@@ -140,17 +98,39 @@
},
{
"filename": "rhel-cloud-init.iso",
- "version": "1.0",
- "md5sum": "421745b0d13615ecd48696f98d8b6352",
+ "version": "1.1",
+ "md5sum": "f1f908d36e67f843dd94db1c2e8c8373",
"filesize": 374784,
- "download_url": "https://gitlab.com/neyder/rhel-cloud-init/raw/master/rhel-cloud-init.iso"
+ "download_url": "https://github.com/GNS3/gns3-registry/tree/master/cloud-init/rhel",
+ "direct_download_url": "https://github.com/GNS3/gns3-registry/raw/master/cloud-init/rhel/rhel-cloud-init.iso"
}
],
"versions": [
{
- "name": "9.5",
+ "name": "10.1",
"images": {
- "hda_disk_image": "rhel-9.5-x86_64-kvm.qcow2",
+ "hda_disk_image": "rhel-10.1-x86_64-kvm.qcow2",
+ "cdrom_image": "rhel-cloud-init.iso"
+ }
+ },
+ {
+ "name": "10.0",
+ "images": {
+ "hda_disk_image": "rhel-10.0-x86_64-kvm.qcow2",
+ "cdrom_image": "rhel-cloud-init.iso"
+ }
+ },
+ {
+ "name": "9.7",
+ "images": {
+ "hda_disk_image": "rhel-9.7-x86_64-kvm.qcow2",
+ "cdrom_image": "rhel-cloud-init.iso"
+ }
+ },
+ {
+ "name": "9.6",
+ "images": {
+ "hda_disk_image": "rhel-9.6-x86_64-kvm.qcow2",
"cdrom_image": "rhel-cloud-init.iso"
}
},
@@ -161,34 +141,6 @@
"cdrom_image": "rhel-cloud-init.iso"
}
},
- {
- "name": "9.3",
- "images": {
- "hda_disk_image": "rhel-9.3-x86_64-kvm.qcow2",
- "cdrom_image": "rhel-cloud-init.iso"
- }
- },
- {
- "name": "9.2",
- "images": {
- "hda_disk_image": "rhel-9.2-x86_64-kvm.qcow2",
- "cdrom_image": "rhel-cloud-init.iso"
- }
- },
- {
- "name": "9.1",
- "images": {
- "hda_disk_image": "rhel-baseos-9.1-x86_64-kvm.qcow2",
- "cdrom_image": "rhel-cloud-init.iso"
- }
- },
- {
- "name": "9.0",
- "images": {
- "hda_disk_image": "rhel-baseos-9.0-x86_64-kvm.qcow2",
- "cdrom_image": "rhel-cloud-init.iso"
- }
- },
{
"name": "8.10",
"images": {
@@ -196,13 +148,6 @@
"cdrom_image": "rhel-cloud-init.iso"
}
},
- {
- "name": "8.9",
- "images": {
- "hda_disk_image": "rhel-8.9-x86_64-kvm.qcow2",
- "cdrom_image": "rhel-cloud-init.iso"
- }
- },
{
"name": "8.8",
"images": {
@@ -210,13 +155,6 @@
"cdrom_image": "rhel-cloud-init.iso"
}
},
- {
- "name": "8.7",
- "images": {
- "hda_disk_image": "rhel-8.7-x86_64-kvm.qcow2",
- "cdrom_image": "rhel-cloud-init.iso"
- }
- },
{
"name": "8.6",
"images": {
@@ -224,31 +162,10 @@
"cdrom_image": "rhel-cloud-init.iso"
}
},
- {
- "name": "8.5",
- "images": {
- "hda_disk_image": "rhel-8.5-x86_64-kvm.qcow2",
- "cdrom_image": "rhel-cloud-init.iso"
- }
- },
- {
- "name": "8.4",
- "images": {
- "hda_disk_image": "rhel-8.4-x86_64-kvm.qcow2",
- "cdrom_image": "rhel-cloud-init.iso"
- }
- },
- {
- "name": "8.3",
- "images": {
- "hda_disk_image": "rhel-8.3-x86_64-kvm.qcow2",
- "cdrom_image": "rhel-cloud-init.iso"
- }
- },
{
"name": "7.9",
"images": {
- "hda_disk_image": "rhel-server-7.9-x86_64-kvm.qcow2",
+ "hda_disk_image": "rhel-server-7.9-update-12-x86_64-kvm.qcow2",
"cdrom_image": "rhel-cloud-init.iso"
}
},
diff --git a/gns3server/appliances/tinycore-linux.gns3a b/gns3server/appliances/tinycore-linux.gns3a
index 7fa608326..f5fd5273e 100644
--- a/gns3server/appliances/tinycore-linux.gns3a
+++ b/gns3server/appliances/tinycore-linux.gns3a
@@ -2,7 +2,7 @@
"appliance_id": "ed6b9f98-7de2-4d61-a3ed-ad4c3e323ace",
"name": "Tiny Core Linux",
"category": "guest",
- "description": "Core Linux is a smaller variant of Tiny Core without a graphical desktop.\n\nIt provides a complete Linux system using only a few MiB.",
+ "description": "TinyCore is a Linux system with a graphical desktop using only a few MiB.",
"vendor_name": "Team Tiny Core",
"vendor_url": "http://distro.ibiblio.org/tinycorelinux",
"documentation_url": "http://wiki.tinycorelinux.net/",
@@ -25,6 +25,14 @@
"options": "-vga std -usbdevice tablet"
},
"images": [
+ {
+ "filename": "linux-tinycore-17.0.qcow2",
+ "version": "17.0",
+ "md5sum": "6434d4da25dd0277cc90b000bb4ac8f1",
+ "filesize": 35061760,
+ "download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/",
+ "direct_download_url": "http://downloads.sourceforge.net/project/gns-3/Qemu%20Appliances/linux-tinycore-17.0.qcow2"
+ },
{
"filename": "linux-tinycore-11.1.qcow2",
"version": "11.1",
@@ -51,6 +59,12 @@
}
],
"versions": [
+ {
+ "name": "17.0",
+ "images": {
+ "hda_disk_image": "linux-tinycore-17.0.qcow2"
+ }
+ },
{
"name": "11.1",
"images": {
diff --git a/gns3server/appliances/vyos.gns3a b/gns3server/appliances/vyos.gns3a
index 10bf9ae4c..f89c82044 100644
--- a/gns3server/appliances/vyos.gns3a
+++ b/gns3server/appliances/vyos.gns3a
@@ -9,27 +9,54 @@
"documentation_url": "https://docs.vyos.io/",
"product_name": "VyOS Universal Router",
"product_url": "https://vyos.io/vyos-universal-router",
- "registry_version": 4,
+ "registry_version": 8,
"status": "stable",
"availability": "service-contract",
"maintainer": "VyOS Inc.",
"maintainer_email": "support@vyos.io",
- "usage": "\nDefault credentials:\nUser: vyos\nPassword: vyos",
+ "installation_instructions": "To install the appliance, you must:\n\n1. Have an active VyOS subscription (for details, visit https://vyos.io/).\n2. Download the QEMU/KVM image for your target version from the support portal: https://support.vyos.io/.\n3. Import the downloaded image for installation in the next step.\n\nAlternatively, you can:\n- Build your own image, or\n- Use any compatible qcow2 images (including rolling or stream releases) by selecting \"Create a new version\" and enabling the \"Allow custom files\" feature.",
+ "usage": "No extra installation steps are required - simply boot the router.\nFor more details, please refer to the documentation: https://docs.vyos.io/",
+ "default_username": "vyos",
+ "default_password": "vyos",
"symbol": "vyos.svg",
- "port_name_format": "eth{0}",
- "qemu": {
- "adapter_type": "virtio-net-pci",
- "adapters": 10,
- "ram": 2048,
- "cpus": 4,
- "hda_disk_interface": "virtio",
- "arch": "x86_64",
- "console_type": "telnet",
- "boot_priority": "c",
- "kvm": "require",
- "on_close": "shutdown_signal"
- },
+ "settings": [
+ {
+ "name": "default x86_64",
+ "default": true,
+ "template_type": "qemu",
+ "template_properties": {
+ "adapter_type": "virtio-net-pci",
+ "adapters": 10,
+ "port_name_format": "eth{0}",
+ "ram": 2048,
+ "cpus": 4,
+ "hda_disk_interface": "virtio",
+ "platform": "x86_64",
+ "console_type": "telnet",
+ "boot_priority": "c",
+ "options": "-cpu host",
+ "uefi": false,
+ "on_close": "shutdown_signal"
+ }
+ },
+ {
+ "name": "1.5 x86_64",
+ "inherit_default_properties": true,
+ "template_type": "qemu",
+ "template_properties": {
+ "ram": 8192,
+ "cpus": 4
+ }
+ }
+ ],
"images": [
+ {
+ "filename": "vyos-1.5.0-kvm-amd64.qcow2",
+ "version": "1.5.0",
+ "md5sum": "f84587bd6c45b139563a1d1d2fe0b2e8",
+ "filesize": 618528768,
+ "download_url": "https://support.vyos.io/"
+ },
{
"filename": "vyos-1.4.4-kvm-amd64.qcow2",
"version": "1.4.4",
@@ -132,11 +159,18 @@
"filename": "vyos-1.2.5-amd64.qcow2",
"version": "1.2.5",
"md5sum": "110c22309ec480600446fd2fb4f27a0d",
- "filesize": 411500544 ,
+ "filesize": 411500544,
"download_url": "https://support.vyos.io/"
}
],
"versions": [
+ {
+ "name": "1.5.0",
+ "settings": "1.5 x86_64",
+ "images": {
+ "hda_disk_image": "vyos-1.5.0-kvm-amd64.qcow2"
+ }
+ },
{
"name": "1.4.4",
"images": {
diff --git a/gns3server/appliances/windows-11-dev-env.gns3a b/gns3server/appliances/windows-11-dev-env.gns3a
index b9b453e8a..f6bbecc65 100644
--- a/gns3server/appliances/windows-11-dev-env.gns3a
+++ b/gns3server/appliances/windows-11-dev-env.gns3a
@@ -30,19 +30,11 @@
},
"images": [
{
- "filename": "WinDev2308Eval-disk1.vmdk",
- "version": "2308",
- "md5sum": "6a9b4ed6d7481f7bbf8a054c797b1eee",
- "filesize": 24945341952,
- "download_url": "https://download.microsoft.com/download/7/1/3/7135f2ab-8528-49fc-9252-8d5d94c697ef/WinDev2308Eval.VMWare.zip",
- "compression": "zip"
- },
- {
- "filename": "WinDev2212Eval-disk1.vmdk",
- "version": "2212",
- "md5sum": "c79f393a067b92e01a513a118d455ac8",
- "filesize": 24620493824,
- "download_url": "https://aka.ms/windev_VM_vmware",
+ "filename": "WinDev2407Eval-disk1.vmdk",
+ "version": "2407",
+ "md5sum": "66e7233c7922ef128c5b7c957d20bcbe",
+ "filesize": 25984826368,
+ "download_url": "https://download.microsoft.com/download/e/e/c/eec4775f-f2e8-4476-98b2-ca51502a6429/WinDev2407Eval.VMWare.zip",
"compression": "zip"
},
{
@@ -57,17 +49,10 @@
],
"versions": [
{
- "name": "2308",
+ "name": "2407",
"images": {
"bios_image": "OVMF-edk2-stable202305.fd",
- "hda_disk_image": "WinDev2308Eval-disk1.vmdk"
- }
- },
- {
- "name": "2212",
- "images": {
- "bios_image": "OVMF-edk2-stable202305.fd",
- "hda_disk_image": "WinDev2212Eval-disk1.vmdk"
+ "hda_disk_image": "WinDev2407Eval-disk1.vmdk"
}
}
]
diff --git a/gns3server/controller/appliance.py b/gns3server/controller/appliance.py
index 9e93ee424..629765b8b 100644
--- a/gns3server/controller/appliance.py
+++ b/gns3server/controller/appliance.py
@@ -44,6 +44,10 @@ class Appliance:
def status(self):
return self._data["status"]
+ @property
+ def registry_version(self):
+ return self._data.get("registry_version")
+
@property
def symbol(self):
return self._data.get("symbol")
diff --git a/gns3server/controller/appliance_manager.py b/gns3server/controller/appliance_manager.py
index ad824e51c..0420e78cc 100644
--- a/gns3server/controller/appliance_manager.py
+++ b/gns3server/controller/appliance_manager.py
@@ -37,6 +37,7 @@ from .appliance_to_template import ApplianceToTemplate
from ..utils.images import InvalidImageError, write_image, read_image_info
from gns3server import schemas
+from gns3server.schemas.controller.appliances import ApplianceModel
from gns3server.utils.images import default_images_directory
from gns3server.db.repositories.images import ImagesRepository
from gns3server.db.repositories.templates import TemplatesRepository
@@ -248,7 +249,8 @@ class ApplianceManager:
appliances_info = self._find_appliances_from_image_checksum(image_checksum)
for appliance, image_version in appliances_info:
try:
- schemas.Appliance.model_validate(appliance.asdict())
+ # Validate with discriminated union - automatically routes to correct version
+ ApplianceModel.model_validate(appliance.asdict())
except ValidationError as e:
log.warning(f"Could not validate appliance '{appliance.id}': {e}")
if appliance.versions:
@@ -279,7 +281,8 @@ class ApplianceManager:
raise ControllerNotFoundError(message=f"Could not find appliance '{appliance_id}'")
try:
- schemas.Appliance.model_validate(appliance.asdict())
+ # Validate with discriminated union - automatically routes to correct version
+ ApplianceModel.model_validate(appliance.asdict())
except ValidationError as e:
raise ControllerError(message=f"Could not validate appliance '{appliance_id}': {e}")
@@ -334,7 +337,9 @@ class ApplianceManager:
appliance = Appliance(path, json.load(f), builtin=builtin)
json_data = appliance.asdict() # Check if loaded without error
if appliance.status != "broken":
- schemas.Appliance.model_validate(json_data)
+ # Validate using discriminated union - automatically routes to correct version
+ log.debug(f"Validating appliance '{appliance.id}' with registry version {appliance.registry_version}")
+ ApplianceModel.model_validate(json_data)
self._appliances[appliance.id] = appliance
if not appliance.symbol or appliance.symbol.startswith(":/symbols/"):
# apply a default symbol if the appliance has none or a default symbol
diff --git a/gns3server/controller/ports/port.py b/gns3server/controller/ports/port.py
index e0a0406ba..afa8d2bec 100644
--- a/gns3server/controller/ports/port.py
+++ b/gns3server/controller/ports/port.py
@@ -83,7 +83,7 @@ class Port:
# If port name format has changed we use the port name as the short name (1.X behavior)
if self._short_name:
return self._short_name
- elif "/" in self._name:
+ elif self._name and '/' in self._name:
return self._name.replace(self.long_name_type(), self.short_name_type())
elif self._name.startswith(f"{self.long_name_type()}{self._interface_number}"):
return self.short_name_type() + f"{self._interface_number}"
diff --git a/gns3server/custom_symbols/AlpiNet.svg b/gns3server/custom_symbols/AlpiNet.svg
new file mode 100644
index 000000000..985d9af01
--- /dev/null
+++ b/gns3server/custom_symbols/AlpiNet.svg
@@ -0,0 +1,40 @@
+
+
+
+
diff --git a/gns3server/custom_symbols/home-assistant-logomark-color-on-light.svg b/gns3server/custom_symbols/home-assistant-logomark-color-on-light.svg
new file mode 100755
index 000000000..7bce628cf
--- /dev/null
+++ b/gns3server/custom_symbols/home-assistant-logomark-color-on-light.svg
@@ -0,0 +1,4 @@
+
diff --git a/gns3server/db/models/users.py b/gns3server/db/models/users.py
index f68bc49fc..5c14c3c15 100644
--- a/gns3server/db/models/users.py
+++ b/gns3server/db/models/users.py
@@ -15,7 +15,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
-from sqlalchemy import Table, Boolean, Column, String, DateTime, ForeignKey, event
+from sqlalchemy import Table, Boolean, Column, Integer, String, DateTime, ForeignKey, event
from sqlalchemy.orm import relationship
from .base import Base, BaseTable, generate_uuid, GUID
@@ -45,6 +45,7 @@ class User(BaseTable):
full_name = Column(String)
hashed_password = Column(String)
last_login = Column(DateTime)
+ token_version = Column(Integer, default=0, nullable=False, server_default="0")
is_active = Column(Boolean, default=True)
is_superadmin = Column(Boolean, default=False)
groups = relationship("UserGroup", secondary=user_group_map, back_populates="users")
diff --git a/gns3server/db/repositories/users.py b/gns3server/db/repositories/users.py
index ea53c2e33..ee005b2bf 100644
--- a/gns3server/db/repositories/users.py
+++ b/gns3server/db/repositories/users.py
@@ -113,6 +113,18 @@ class UsersRepository(BaseRepository):
await self._db_session.refresh(user_db) # force refresh of updated_at value
return user_db
+ async def logout_user(self, user_id: UUID) -> None:
+ """
+ Increment token_version to invalidate all existing tokens for the user.
+ """
+
+ query = update(models.User).\
+ where(models.User.user_id == user_id).\
+ values(token_version=models.User.token_version + 1)
+
+ await self._db_session.execute(query)
+ await self._db_session.commit()
+
async def delete_user(self, user_id: UUID) -> bool:
"""
Delete a user.
diff --git a/gns3server/db_migrations/versions/aff810fc119a_add_token_version_to_users_table.py b/gns3server/db_migrations/versions/aff810fc119a_add_token_version_to_users_table.py
new file mode 100644
index 000000000..f9f82cc43
--- /dev/null
+++ b/gns3server/db_migrations/versions/aff810fc119a_add_token_version_to_users_table.py
@@ -0,0 +1,24 @@
+"""add token version to users table
+
+Revision ID: aff810fc119a
+Revises: ec4b7b198555
+Create Date: 2026-04-06 19:49:12.155446
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'aff810fc119a'
+down_revision = 'ec4b7b198555'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+
+ op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
+
+def downgrade() -> None:
+
+ op.drop_column('users', 'token_version')
diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py
index 623a3d11d..beb8f3b6b 100644
--- a/gns3server/schemas/__init__.py
+++ b/gns3server/schemas/__init__.py
@@ -24,7 +24,7 @@ from .controller.links import LinkCreate, LinkUpdate, Link, UDPPortInfo, Etherne
from .controller.computes import ComputeCreate, ComputeUpdate, ComputeVirtualBoxVM, ComputeVMwareVM, ComputeDockerImage, AutoIdlePC, Compute
from .controller.templates import TemplateCreate, TemplateUpdate, TemplateUsage, Template
from .controller.images import Image, ImageType
-from .controller.appliances import ApplianceVersion, Appliance
+from .controller.appliances import ApplianceVersion, ApplianceVersionV8, Appliance
from .controller.drawings import Drawing
from .controller.gns3vm import GNS3VM
from .controller.nodes import NodeCreate, NodeUpdate, NodeDuplicate, NodeCapture, Node
diff --git a/gns3server/schemas/controller/appliances.py b/gns3server/schemas/controller/appliances.py
index e88a73394..624db47e4 100644
--- a/gns3server/schemas/controller/appliances.py
+++ b/gns3server/schemas/controller/appliances.py
@@ -1,5 +1,5 @@
#
-# Copyright (C) 2021 GNS3 Technologies Inc.
+# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -14,15 +14,20 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
-# Generated from JSON schema using https://github.com/koxudaxi/datamodel-code-generator
+# Unified Pydantic model supporting both appliance registry versions using discriminated unions
from enum import Enum
-from typing import List, Optional, Union
+from typing import Annotated, List, Literal, Optional, Union
from uuid import UUID
-from pydantic import AnyUrl, BaseModel, EmailStr, Field, confloat, conint, constr
+from pydantic import AnyUrl, BaseModel, Discriminator, EmailStr, Field, Tag
+# ============================================================================
+# Shared Enums
+# ============================================================================
+
class Category(str, Enum):
+ """Appliance category enum"""
router = 'router'
multilayer_switch = 'multilayer_switch'
@@ -31,17 +36,8 @@ class Category(str, Enum):
guest = 'guest'
-class RegistryVersion(int, Enum):
-
- version1 = 1
- version2 = 2
- version3 = 3
- version4 = 4
- version5 = 5
- version6 = 6
-
-
class Status(str, Enum):
+ """Appliance status enum"""
stable = 'stable'
experimental = 'experimental'
@@ -49,6 +45,7 @@ class Status(str, Enum):
class Availability(str, Enum):
+ """Image availability enum"""
free = 'free'
with_registration = 'with-registration'
@@ -56,52 +53,32 @@ class Availability(str, Enum):
service_contract = 'service-contract'
-class ConsoleType(str, Enum):
+class Compression(str, Enum):
+ """Compression type enum"""
- telnet = 'telnet'
- vnc = 'vnc'
- http = 'http'
- https = 'https'
- none = 'none'
+ bzip2 = 'bzip2'
+ gzip = 'gzip'
+ lzma = 'lzma'
+ xz = 'xz'
+ rar = 'rar'
+ zip = 'zip'
+ field_7z = '7z'
-class Docker(BaseModel):
+class DynamipsPlatform(str, Enum):
+ """Dynamips platform enum"""
- adapters: int = Field(..., title='Number of ethernet adapters')
- image: str = Field(..., title='Docker image in the Docker Hub')
- start_command: Optional[str] = Field(
- None,
- title='Command executed when the container start. Empty will use the default',
- )
- environment: Optional[str] = Field(None, title='One KEY=VAR environment by line')
- console_type: Optional[ConsoleType] = Field(
- None, title='Type of console connection for the administration of the appliance'
- )
- console_http_port: Optional[int] = Field(
- None, description='Internal port in the container of the HTTP server'
- )
- console_http_path: Optional[str] = Field(
- None, description='Path of the web interface'
- )
- extra_hosts: Optional[str] = Field(
- None, description='Hosts which will be written to /etc/hosts into container'
- )
- extra_volumes: Optional[List[str]] = Field(
- None,
- description='Additional directories to make persistent that are not included in the images VOLUME directive',
- )
+ c1700 = 'c1700'
+ c2600 = 'c2600'
+ c2691 = 'c2691'
+ c3725 = 'c3725'
+ c3745 = 'c3745'
+ c3600 = 'c3600'
+ c7200 = 'c7200'
-class Iou(BaseModel):
-
- ethernet_adapters: int = Field(..., title='Number of ethernet adapters')
- serial_adapters: int = Field(..., title='Number of serial adapters')
- nvram: int = Field(..., title='Host NVRAM')
- ram: int = Field(..., title='Host RAM')
- startup_config: str = Field(..., title='Config loaded at startup')
-
-
-class Chassis(str, Enum):
+class DynamipsChassis(str, Enum):
+ """Dynamips chassis enum"""
chassis_1720 = '1720'
chassis_1721 = '1721'
@@ -120,26 +97,56 @@ class Chassis(str, Enum):
chassis_3620 = '3620'
chassis_3640 = '3640'
chassis_3660 = '3660'
+ _ = ''
-class Platform(str, Enum):
+class DynamipsSlot(str, Enum):
+ """Dynamips slot enum"""
- c1700 = 'c1700'
- c2600 = 'c2600'
- c2691 = 'c2691'
- c3725 = 'c3725'
- c3745 = 'c3745'
- c3600 = 'c3600'
- c7200 = 'c7200'
+ C7200_IO_2FE = 'C7200-IO-2FE'
+ C7200_IO_FE = 'C7200-IO-FE'
+ C7200_IO_GE_E = 'C7200-IO-GE-E'
+ NM_16ESW = 'NM-16ESW'
+ NM_1E = 'NM-1E'
+ NM_1FE_TX = 'NM-1FE-TX'
+ NM_4E = 'NM-4E'
+ NM_4T = 'NM-4T'
+ PA_2FE_TX = 'PA-2FE-TX'
+ PA_4E = 'PA-4E'
+ PA_4T_ = 'PA-4T+'
+ PA_8E = 'PA-8E'
+ PA_8T = 'PA-8T'
+ PA_A1 = 'PA-A1'
+ PA_FE_TX = 'PA-FE-TX'
+ PA_GE = 'PA-GE'
+ PA_POS_OC3 = 'PA-POS-OC3'
+ C2600_MB_2FE = 'C2600-MB-2FE'
+ C2600_MB_1E = 'C2600-MB-1E'
+ C1700_MB_1FE = 'C1700-MB-1FE'
+ C2600_MB_2E = 'C2600-MB-2E'
+ C2600_MB_1FE = 'C2600-MB-1FE'
+ C1700_MB_WIC1 = 'C1700-MB-WIC1'
+ GT96100_FE = 'GT96100-FE'
+ Leopard_2FE = 'Leopard-2FE'
+ _ = ''
+class DynamipsWic(str, Enum):
+ """Dynamips WIC enum"""
-class Midplane(str, Enum):
+ WIC_1ENET = 'WIC-1ENET'
+ WIC_1T = 'WIC-1T'
+ WIC_2T = 'WIC-2T'
+ _ = ''
+
+class DynamipsMidplane(str, Enum):
+ """Dynamips midplane enum"""
std = 'std'
vxr = 'vxr'
-class Npe(str, Enum):
+class DynamipsNpe(str, Enum):
+ """Dynamips NPE enum"""
npe_100 = 'npe-100'
npe_150 = 'npe-150'
@@ -151,12 +158,20 @@ class Npe(str, Enum):
npe_g2 = 'npe-g2'
-class AdapterType(str, Enum):
+class QemuConsoleType(str, Enum):
+ """Qemu console type enum"""
+
+ telnet = 'telnet'
+ vnc = 'vnc'
+ spice = 'spice'
+ spice_agent = 'spice+agent'
+ none = 'none'
+
+
+class QemuAdapterType(str, Enum):
+ """Qemu adapter type enum"""
e1000 = 'e1000'
- e1000_82544gc = 'e1000-82544gc'
- e1000_82545em = 'e1000-82545em'
- e1000e = 'e1000e'
i82550 = 'i82550'
i82551 = 'i82551'
i82557a = 'i82557a'
@@ -173,28 +188,14 @@ class AdapterType(str, Enum):
igb = 'igb'
ne2k_pci = 'ne2k_pci'
pcnet = 'pcnet'
- rocker = 'rocker'
rtl8139 = 'rtl8139'
virtio = 'virtio'
virtio_net_pci = 'virtio-net-pci'
vmxnet3 = 'vmxnet3'
-class DiskInterface(str, Enum):
-
- ide = 'ide'
- sata = 'sata'
- nvme = 'nvme'
- scsi = 'scsi'
- sd = 'sd'
- mtd = 'mtd'
- floppy = 'floppy'
- pflash = 'pflash'
- virtio = 'virtio'
- none = 'none'
-
-
-class Arch(str, Enum):
+class QemuPlatform(str, Enum):
+ """Qemu platform enum"""
aarch64 = 'aarch64'
alpha = 'alpha'
@@ -226,16 +227,23 @@ class Arch(str, Enum):
xtensaeb = 'xtensaeb'
-class ConsoleType1(str, Enum):
+class QemuDiskInterface(str, Enum):
+ """Disk interface enum"""
- telnet = 'telnet'
- vnc = 'vnc'
- spice = 'spice'
- spice_agent = 'spice+agent'
+ ide = 'ide'
+ sata = 'sata'
+ nvme = 'nvme'
+ scsi = 'scsi'
+ sd = 'sd'
+ mtd = 'mtd'
+ floppy = 'floppy'
+ pflash = 'pflash'
+ virtio = 'virtio'
none = 'none'
-class BootPriority(str, Enum):
+class QemuBootPriority(str, Enum):
+ """Boot priority enum"""
c = 'c'
d = 'd'
@@ -248,14 +256,16 @@ class BootPriority(str, Enum):
nd = 'nd'
-class Kvm(str, Enum):
+class QemuOnClose(str, Enum):
+ """Qemu on_close action enum"""
- require = 'require'
- allow = 'allow'
- disable = 'disable'
+ power_off = 'power_off'
+ shutdown_signal = 'shutdown_signal'
+ save_vm_state = 'save_vm_state'
-class ProcessPriority(str, Enum):
+class QemuProcessPriority(str, Enum):
+ """Qemu process priority enum"""
realtime = 'realtime'
very_high = 'very high'
@@ -263,80 +273,117 @@ class ProcessPriority(str, Enum):
normal = 'normal'
low = 'low'
very_low = 'very low'
- null = 'null'
+
+
+class DockerConsoleType(str, Enum):
+ """Docker console type enum"""
+
+ telnet = 'telnet'
+ vnc = 'vnc'
+ http = 'http'
+ https = 'https'
+ none = 'none'
+
+
+class ChecksumType(str, Enum):
+ """Checksum type enum"""
+
+ md5 = 'md5'
+
+
+class TemplateType(str, Enum):
+ """Template type enum"""
+
+ docker = 'docker'
+ iou = 'iou'
+ dynamips = 'dynamips'
+ qemu = 'qemu'
+
+# ============================================================================
+# Version 1-6 Specific Enums
+# ============================================================================
+
+class Kvm(str, Enum):
+ """KVM requirements enum"""
+
+ require = 'require'
+ allow = 'allow'
+ disable = 'disable'
+
+# ============================================================================
+# Version 1-6 Models
+# ============================================================================
+
+class Docker(BaseModel):
+ """Docker configuration for v1-6"""
+
+ adapters: int = Field(..., title='Number of Ethernet adapters')
+ image: str = Field(..., title='Docker image in the Docker Hub')
+ start_command: Optional[str] = Field(None, title='Command executed when the container start. Empty will use the default')
+ environment: Optional[str] = Field(None, title='One KEY=VAR environment by line')
+ console_type: Optional[DockerConsoleType] = Field(None, title='Type of console connection for the administration of the appliance')
+ console_http_port: Optional[int] = Field(None, description='Internal port in the container of the HTTP server')
+ console_http_path: Optional[str] = Field(None, description='Path of the web interface')
+ extra_hosts: Optional[str] = Field(None, description='Hosts which will be written to /etc/hosts into container')
+ extra_volumes: Optional[List[str]] = Field(None, description='Additional directories to make persistent that are not included in the images VOLUME directive')
+
+
+class Iou(BaseModel):
+ """IOU configuration for v1-6"""
+
+ ethernet_adapters: int = Field(..., title='Number of Ethernet adapters')
+ serial_adapters: int = Field(..., title='Number of serial adapters')
+ nvram: int = Field(..., title='Host NVRAM')
+ ram: int = Field(..., title='Host RAM')
+ startup_config: str = Field(..., title='Config loaded at startup')
+
+
+class Dynamips(BaseModel):
+ """Dynamips configuration for v1-6"""
+
+ chassis: Optional[DynamipsChassis] = Field(None, title='Chassis type')
+ platform: DynamipsPlatform = Field(..., title='Platform type')
+ ram: Annotated[int, Field(ge=1)] = Field(..., title='Amount of ram')
+ nvram: Annotated[int, Field(ge=1)] = Field(..., title='Amount of nvram')
+ startup_config: Optional[str] = Field(None, title='Config loaded at startup')
+ wic0: Optional[DynamipsWic] = None
+ wic1: Optional[DynamipsWic] = None
+ wic2: Optional[DynamipsWic] = None
+ slot0: Optional[DynamipsSlot] = None
+ slot1: Optional[DynamipsSlot] = None
+ slot2: Optional[DynamipsSlot] = None
+ slot3: Optional[DynamipsSlot] = None
+ slot4: Optional[DynamipsSlot] = None
+ slot5: Optional[DynamipsSlot] = None
+ slot6: Optional[DynamipsSlot] = None
+ midplane: Optional[DynamipsMidplane] = None
+ npe: Optional[DynamipsNpe] = None
class Qemu(BaseModel):
+ """QEMU configuration for v1-6"""
- adapter_type: AdapterType = Field(..., title='Type of network adapter')
+ adapter_type: QemuAdapterType = Field(..., title='Type of network adapter')
adapters: int = Field(..., title='Number of adapters')
- ram: int = Field(..., title='Ram allocated to the appliance (MB)')
+ ram: int = Field(..., title='RAM allocated to the appliance (MB)')
cpus: Optional[int] = Field(None, title='Number of Virtual CPU')
- hda_disk_interface: Optional[DiskInterface] = Field(
- None, title='Disk interface for the installed hda_disk_image'
- )
- hdb_disk_interface: Optional[DiskInterface] = Field(
- None, title='Disk interface for the installed hdb_disk_image'
- )
- hdc_disk_interface: Optional[DiskInterface] = Field(
- None, title='Disk interface for the installed hdc_disk_image'
- )
- hdd_disk_interface: Optional[DiskInterface] = Field(
- None, title='Disk interface for the installed hdd_disk_image'
- )
- arch: Arch = Field(..., title='Architecture emulated')
- console_type: ConsoleType1 = Field(
- ..., title='Type of console connection for the administration of the appliance'
- )
- boot_priority: Optional[BootPriority] = Field(
- None,
- title='Disk boot priority. Refer to -boot option in qemu manual for more details.',
- )
- kernel_command_line: Optional[str] = Field(
- None, title='Command line parameters send to the kernel'
- )
+ hda_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hda_disk_image')
+ hdb_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdb_disk_image')
+ hdc_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdc_disk_image')
+ hdd_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdd_disk_image')
+ arch: QemuPlatform = Field(..., title='Architecture emulated')
+ console_type: QemuConsoleType = Field(..., title='Type of console connection for the administration of the appliance')
+ boot_priority: Optional[QemuBootPriority] = Field(None, title='Disk boot priority')
+ kernel_command_line: Optional[str] = Field(None, title='Command line parameters sent to the kernel')
kvm: Kvm = Field(..., title='KVM requirements')
- options: Optional[str] = Field(
- None, title='Optional additional qemu command line options'
- )
- cpu_throttling: Optional[confloat(ge=0.0, le=100.0)] = Field(
- None, title='Throttle the CPU'
- )
- process_priority: Optional[ProcessPriority] = Field(
- None, title='Process priority for QEMU'
- )
-
-
-class Compression(str, Enum):
-
- bzip2 = 'bzip2'
- gzip = 'gzip'
- lzma = 'lzma'
- xz = 'xz'
- rar = 'rar'
- zip = 'zip'
- field_7z = '7z'
-
-
-class ApplianceImage(BaseModel):
-
- filename: str = Field(..., title='Filename')
- version: str = Field(..., title='Version of the file')
- md5sum: str = Field(..., title='md5sum of the file', pattern='^[a-f0-9]{32}$')
- filesize: int = Field(..., title='File size in bytes')
- download_url: Optional[Union[AnyUrl, constr(max_length=0)]] = Field(
- None, title='Download url where you can download the appliance from a browser'
- )
- direct_download_url: Optional[Union[AnyUrl, constr(max_length=0)]] = Field(
- None,
- title='Optional. Non authenticated url to the image file where you can download the image.',
- )
- compression: Optional[Compression] = Field(
- None, title='Optional, compression type of direct download url image.'
- )
+ options: Optional[str] = Field(None, title='Optional additional qemu command line options')
+ cpu_throttling: Optional[Annotated[float, Field(ge=0.0, le=100.0)]] = Field(None, title='Throttle the CPU')
+ on_close: Optional[QemuOnClose] = Field(None, title='Action to execute on the VM is closed')
+ process_priority: Optional[QemuProcessPriority] = Field(None, title='Process priority for QEMU')
class ApplianceVersionImages(BaseModel):
+ """Appliance version images configuration for v1-6"""
kernel_image: Optional[str] = Field(None, title='Kernel image')
initrd: Optional[str] = Field(None, title='Initrd disk image')
@@ -350,101 +397,141 @@ class ApplianceVersionImages(BaseModel):
class ApplianceVersion(BaseModel):
+ """Appliance version definition for v1-6"""
name: str = Field(..., title='Name of the version')
idlepc: Optional[str] = Field(None, pattern='^0x[0-9a-f]{8}')
images: Optional[ApplianceVersionImages] = Field(None, title='Images used for this version')
-class DynamipsSlot(str, Enum):
+class ApplianceImage(BaseModel):
+ """Appliance image definition - compatible with both versions"""
- C7200_IO_2FE = 'C7200-IO-2FE'
- C7200_IO_FE = 'C7200-IO-FE'
- C7200_IO_GE_E = 'C7200-IO-GE-E'
- NM_16ESW = 'NM-16ESW'
- NM_1E = 'NM-1E'
- NM_1FE_TX = 'NM-1FE-TX'
- NM_4E = 'NM-4E'
- NM_4T = 'NM-4T'
- PA_2FE_TX = 'PA-2FE-TX'
- PA_4E = 'PA-4E'
- PA_4T_ = 'PA-4T+'
- PA_8E = 'PA-8E'
- PA_8T = 'PA-8T'
- PA_A1 = 'PA-A1'
- PA_FE_TX = 'PA-FE-TX'
- PA_GE = 'PA-GE'
- PA_POS_OC3 = 'PA-POS-OC3'
- C2600_MB_2FE = 'C2600-MB-2FE'
- C2600_MB_1E = 'C2600-MB-1E'
- C1700_MB_1FE = 'C1700-MB-1FE'
- C2600_MB_2E = 'C2600-MB-2E'
- C2600_MB_1FE = 'C2600-MB-1FE'
- C1700_MB_WIC1 = 'C1700-MB-WIC1'
- GT96100_FE = 'GT96100-FE'
- Leopard_2FE = 'Leopard-2FE'
- _ = ''
+ filename: str = Field(..., title='Filename')
+ version: str = Field(..., title='Version of the file')
+ md5sum: Optional[str] = Field(None, title='md5sum of the file', pattern='^[a-f0-9]{32}$')
+ filesize: int = Field(..., title='File size in bytes')
+ download_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field(
+ None,
+ title='Download url where you can download the appliance from a browser'
+ )
+ direct_download_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field(
+ None,
+ title='Optional. Non authenticated url to the image file where you can download the image.',
+ )
+ compression: Optional[Compression] = Field(
+ None, title='Optional, compression type of direct download url image.'
+ )
+ checksum: Optional[str] = Field(None, title='checksum of the image file')
+ checksum_type: Optional[ChecksumType] = Field(None, title='checksum type of the image file')
+ compression_target: Optional[str] = Field(
+ None, title='Optional, file name of the image file inside the compressed file.'
+ )
-class DynamipsWic(str, Enum):
+# ============================================================================
+# Version 8 Models
+# ============================================================================
- WIC_1ENET = 'WIC-1ENET'
- WIC_1T = 'WIC-1T'
- WIC_2T = 'WIC-2T'
+class CustomAdapterItem(BaseModel):
+ """Custom adapter configuration (v8)"""
+
+ adapter_number: int = Field(..., title='Adapter number')
+ port_name: Optional[str] = Field(None, title='Custom port name')
+ adapter_type: Optional[QemuAdapterType] = Field(None, title='Custom adapter type')
+ mac_address: Optional[str] = Field(
+ None,
+ title='Custom MAC address',
+ pattern=r'^([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})$',
+ )
-class Dynamips(BaseModel):
+class DockerPropertiesV8(BaseModel):
+ """Docker template properties (v8)"""
- chassis: Optional[Chassis] = Field(None, title='Chassis type')
- platform: Platform = Field(..., title='Platform type')
- ram: conint(ge=1) = Field(..., title='Amount of ram')
- nvram: conint(ge=1) = Field(..., title='Amount of nvram')
+ name: Optional[str] = Field(None, title='Name of the template')
+ category: Optional[Category] = Field(None, title='Category of the template')
+ default_name_format: Optional[str] = Field(None, title='Default name format')
+ usage: Optional[str] = Field(None, title='How to use the template')
+ symbol: Optional[str] = Field(None, title='Symbol of the template')
+ image: str = Field(..., title='Docker image')
+ adapters: Optional[int] = Field(None, title='Number of ethernet adapters')
+ start_command: Optional[str] = Field(
+ None, title='Command executed when the container start. Empty will use the default'
+ )
+ environment: Optional[str] = Field(None, title='One KEY=VAR environment by line')
+ console_type: Optional[DockerConsoleType] = Field(
+ None, title='Type of console'
+ )
+ console_http_port: Optional[int] = Field(
+ None, title='Internal port in the container of the HTTP server'
+ )
+ console_http_path: Optional[str] = Field(None, title='Path of the web interface')
+ console_resolution: Optional[str] = Field(
+ None,
+ title='Console resolution for VNC, for example 1024x768',
+ pattern=r'^[0-9]+x[0-9]+$',
+ )
+ extra_hosts: Optional[str] = Field(None, title='Docker extra hosts (added to /etc/hosts)')
+ extra_volumes: Optional[List[str]] = Field(
+ None, title='Additional directories to make persistent'
+ )
+
+
+class IouPropertiesV8(BaseModel):
+ """IOU template properties (v8)"""
+
+ name: Optional[str] = Field(None, title='Name of the template')
+ category: Optional[Category] = Field(None, title='Category of the template')
+ default_name_format: Optional[str] = Field(None, title='Default name format')
+ usage: Optional[str] = Field(None, title='How to use the template')
+ symbol: Optional[str] = Field(None, title='Symbol of the template')
+ ethernet_adapters: Optional[int] = Field(None, title='Number of ethernet adapters')
+ serial_adapters: Optional[int] = Field(None, title='Number of serial adapters')
+ ram: Optional[int] = Field(None, title='Host RAM')
+ nvram: Optional[int] = Field(None, title='Host NVRAM')
startup_config: Optional[str] = Field(None, title='Config loaded at startup')
- wic0: Optional[DynamipsWic] = None
- wic1: Optional[DynamipsWic] = None
- wic2: Optional[DynamipsWic] = None
- slot0: Optional[DynamipsSlot] = None
- slot1: Optional[DynamipsSlot] = None
- slot2: Optional[DynamipsSlot] = None
- slot3: Optional[DynamipsSlot] = None
- slot4: Optional[DynamipsSlot] = None
- slot5: Optional[DynamipsSlot] = None
- slot6: Optional[DynamipsSlot] = None
- midplane: Optional[Midplane] = None
- npe: Optional[Npe] = None
-class Appliance(BaseModel):
+class DynamipsPropertiesV8(BaseModel):
+ """Dynamips template properties (v8)"""
- appliance_id: UUID = Field(..., title='Appliance ID')
- name: str = Field(..., title='Appliance name')
- builtin: Optional[bool] = Field(None, title='Whether the appliance is builtin or not')
- category: Category = Field(..., title='Category of the appliance')
- description: str = Field(
- ..., title='Description of the appliance. Could be a marketing description'
- )
- vendor_name: str = Field(..., title='Name of the vendor')
- vendor_url: Optional[Union[AnyUrl, constr(max_length=0)]] = Field(None, title='Website of the vendor')
- documentation_url: Optional[Union[AnyUrl, constr(max_length=0)]] = Field(
- None,
- title='An optional documentation for using the appliance on vendor website',
- )
- product_name: str = Field(..., title='Product name')
- product_url: Optional[Union[AnyUrl, constr(max_length=0)]] = Field(
- None, title='An optional product url on vendor website'
- )
- registry_version: RegistryVersion = Field(
- ..., title='Version of the registry compatible with this appliance'
- )
- status: Status = Field(..., title='Document if the appliance is working or not')
- availability: Optional[Availability] = Field(
- None,
- title='About image availability: can be downloaded directly; download requires a free registration; paid but a trial version (time or feature limited) is available; not available publicly',
- )
- maintainer: str = Field(..., title='Maintainer name')
- maintainer_email: Optional[Union[EmailStr, constr(max_length=0)]] = Field(None, title='Maintainer email')
- usage: Optional[str] = Field(None, title='How to use the appliance')
- symbol: Optional[str] = Field(None, title='An optional symbol for the appliance')
+ name: Optional[str] = Field(None, title='Name of the template')
+ category: Optional[Category] = Field(None, title='Category of the template')
+ default_name_format: Optional[str] = Field(None, title='Default name format')
+ usage: Optional[str] = Field(None, title='How to use the template')
+ symbol: Optional[str] = Field(None, title='Symbol of the template')
+ chassis: Optional[DynamipsChassis] = Field(None, title='Chassis type')
+ platform: Optional[DynamipsPlatform] = Field(None, title='Platform type')
+ ram: Optional[Annotated[int, Field(ge=1)]] = Field(None, title='Amount of ram')
+ nvram: Optional[Annotated[int, Field(ge=1)]] = Field(None, title='Amount of nvram')
+ idlepc: Optional[str] = Field(None, pattern=r'^0x[0-9a-f]{8}')
+ startup_config: Optional[str] = Field(None, title='Config loaded at startup')
+ wic0: Optional[str] = Field(None)
+ wic1: Optional[str] = Field(None)
+ wic2: Optional[str] = Field(None)
+ slot0: Optional[str] = Field(None)
+ slot1: Optional[str] = Field(None)
+ slot2: Optional[str] = Field(None)
+ slot3: Optional[str] = Field(None)
+ slot4: Optional[str] = Field(None)
+ slot5: Optional[str] = Field(None)
+ slot6: Optional[str] = Field(None)
+ midplane: Optional[DynamipsMidplane] = Field(None)
+ npe: Optional[DynamipsNpe] = Field(None)
+
+
+class QemuPropertiesV8(BaseModel):
+ """Qemu template properties (v8)"""
+
+ name: Optional[str] = Field(None, title='Name of the template')
+ category: Optional[Category] = Field(None, title='Category of the template')
+ default_name_format: Optional[str] = Field(None, title='Default name format')
+ usage: Optional[str] = Field(None, title='How to use the template')
+ symbol: Optional[str] = Field(None, title='Symbol of the template')
+ adapter_type: Optional[QemuAdapterType] = Field(None, title='Type of network adapter')
+ adapters: Optional[int] = Field(None, title='Number of adapters')
+ custom_adapters: Optional[List[CustomAdapterItem]] = Field(None, title='Custom adapters')
first_port_name: Optional[str] = Field(
None, title='Optional name of the first networking port example: eth0'
)
@@ -455,12 +542,177 @@ class Appliance(BaseModel):
None,
title='Optional port segment size. A port segment is a block of port. For example Ethernet0/0 Ethernet0/1 is the module 0 with a port segment size of 2',
)
- linked_clone: Optional[bool] = Field(
- None, title="False if you don't want to use a single image for all nodes"
+ linked_clone: Optional[bool] = Field(None, title="False if you don't want to use a single image for all nodes")
+ ram: Optional[int] = Field(None, title='Ram allocated to the appliance (MB)')
+ cpus: Optional[int] = Field(None, title='Number of Virtual CPU')
+ hda_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hda_disk_image')
+ hdb_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdb_disk_image')
+ hdc_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdc_disk_image')
+ hdd_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdd_disk_image')
+ platform: Optional[QemuPlatform] = Field(None, title='Platform to emulate')
+ console_type: Optional[QemuConsoleType] = Field(
+ None, title='Type of console connection for the administration of the appliance'
)
+ boot_priority: Optional[QemuBootPriority] = Field(
+ None,
+ title='Optional define the disk boot priory. Refer to -boot option in qemu manual for more details.',
+ )
+ kernel_command_line: Optional[str] = Field(None, title='Command line parameters send to the kernel')
+ options: Optional[str] = Field(None, title='Optional additional qemu command line options')
+ cpu_throttling: Optional[Annotated[float, Field(ge=0.0, le=100.0)]] = Field(None, title='Throttle the CPU')
+ tpm: Optional[bool] = Field(None, title='Enable the Trusted Platform Module (TPM)')
+ uefi: Optional[bool] = Field(None, title='Enable the UEFI boot mode')
+ on_close: Optional[QemuOnClose] = Field(None, title='Action to execute on the VM is closed')
+ process_priority: Optional[QemuProcessPriority] = Field(None, title='Process priority for QEMU')
+
+
+class TemplateSetting(BaseModel):
+ """Emulator settings configuration (v8)"""
+
+ name: Optional[str] = Field(None, title='Name of the settings set')
+ default: Optional[bool] = Field(None, title='Whether these are the default settings')
+ inherit_default_properties: Optional[bool] = Field(True, title='Whether the default properties should be used')
+ template_type: TemplateType = Field(..., title='Type of emulator properties')
+ template_properties: Union[QemuPropertiesV8, DynamipsPropertiesV8, IouPropertiesV8, DockerPropertiesV8] = Field(
+ ...,
+ title='Properties for the template'
+ )
+
+
+class ApplianceVersionV8(BaseModel):
+ """Appliance version definition (v8)"""
+
+ name: str = Field(..., title='Name of the version')
+ settings: Optional[str] = Field(None, title='Template settings to use to run the version')
+ category: Optional[Category] = Field(None, title='Category of the version')
+ installation_instructions: Optional[str] = Field(None, title='Optional installation instructions for the version')
+ usage: Optional[str] = Field(None, title='Optional instructions about using the version')
+ default_username: Optional[str] = Field(None, title='Default username for the version')
+ default_password: Optional[str] = Field(None, title='Default password for the version')
+ symbol: Optional[str] = Field(None, title='An optional symbol for the version')
+ images: Optional[ApplianceVersionImages] = Field(None, title='Images used for this version')
+
+
+# ============================================================================
+# Child Models with Discriminated Union
+# ============================================================================
+
+class ApplianceV1_6(BaseModel):
+ """GNS3 Appliance model for registry versions 1-6"""
+
+ registry_version: Literal[1, 2, 3, 4, 5, 6] = Field(..., title='Version of the registry compatible with this appliance')
+ appliance_id: UUID = Field(..., title='Appliance ID')
+ name: str = Field(..., title='Appliance name')
+ builtin: Optional[bool] = Field(None, title='Whether the appliance is builtin or not')
+ category: Category = Field(..., title='Category of the appliance')
+ description: str = Field(..., title='Description of the appliance. Could be a marketing description')
+ vendor_name: str = Field(..., title='Name of the vendor')
+ vendor_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field(None, title='Website of the vendor')
+ documentation_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field(
+ None,
+ title='An optional documentation for using the appliance on vendor website'
+ )
+ product_name: str = Field(..., title='Product name')
+ product_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field(None, title='An optional product url on vendor website')
+ status: Status = Field(..., title='Document if the appliance is working or not')
+ availability: Optional[Availability] = Field(
+ None,
+ title='About image availability: can be downloaded directly; download requires a free registration; paid but a trial version (time or feature limited) is available; not available publicly',
+ )
+ maintainer: str = Field(..., title='Maintainer name')
+ maintainer_email: Optional[Union[EmailStr, Annotated[str, Field(max_length=0)]]] = Field(None, title='Maintainer email')
+ usage: Optional[str] = Field(None, title='How to use the appliance')
+ symbol: Optional[str] = Field(None, title='An optional symbol for the appliance')
+ first_port_name: Optional[str] = Field(None, title='Optional name of the first networking port example: eth0')
+ port_name_format: Optional[str] = Field(None, title='Optional formating of the networking port example: eth{0}')
+ port_segment_size: Optional[int] = Field(
+ None,
+ title='Optional port segment size. A port segment is a block of port. For example Ethernet0/0 Ethernet0/1 is the module 0 with a port segment size of 2',
+ )
+ linked_clone: Optional[bool] = Field(None, title="False if you don't want to use a single image for all nodes")
docker: Optional[Docker] = Field(None, title='Docker specific options')
iou: Optional[Iou] = Field(None, title='IOU specific options')
dynamips: Optional[Dynamips] = Field(None, title='Dynamips specific options')
qemu: Optional[Qemu] = Field(None, title='Qemu specific options')
images: Optional[List[ApplianceImage]] = Field(None, title='Images for this appliance')
versions: Optional[List[ApplianceVersion]] = Field(None, title='Versions of the appliance')
+
+
+class ApplianceV8(BaseModel):
+ """GNS3 Appliance model for registry version 8"""
+
+ registry_version: Literal[8] = Field(
+ ...,
+ title='Version of the registry compatible with this appliance (version >=8 introduced breaking changes)',
+ )
+ appliance_id: str = Field(
+ ...,
+ title='Appliance ID',
+ pattern=r'^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$',
+ )
+ name: str = Field(..., title='Appliance name')
+ builtin: Optional[bool] = Field(None, title='Whether the appliance is builtin or not')
+ category: Category = Field(..., title='Category of the appliance')
+ description: str = Field(..., title='Description of the appliance. Could be a marketing description')
+ vendor_name: str = Field(..., title='Name of the vendor')
+ vendor_url: AnyUrl = Field(..., title='Website of the vendor')
+ vendor_logo_url: Optional[AnyUrl] = Field(None, title='Link to the vendor logo (used by the GNS3 marketplace)')
+ documentation_url: Optional[AnyUrl] = Field(None, title='An optional documentation for using the appliance on vendor website')
+ product_name: str = Field(..., title='Product name')
+ product_url: Optional[AnyUrl] = Field(None, title='An optional product url on vendor website')
+ status: Status = Field(..., title='Document if the appliance is working or not')
+ availability: Optional[Availability] = Field(
+ None,
+ title='About image availability: can be downloaded directly; download requires a free registration; paid but a trial version (time or feature limited) is available; not available publicly',
+ )
+ maintainer: str = Field(..., title='Maintainer name')
+ maintainer_email: EmailStr = Field(..., title='Maintainer email')
+ installation_instructions: Optional[str] = Field(None, title='Optional installation instructions')
+ usage: Optional[str] = Field(None, title='How to use the appliance')
+ default_username: Optional[str] = Field(None, title='Default username for the appliance')
+ default_password: Optional[str] = Field(None, title='Default password for the appliance')
+ symbol: Optional[str] = Field(None, title='An optional symbol for the appliance')
+ settings: List[TemplateSetting] = Field(..., title='Settings for running the appliance')
+ images: Optional[List[ApplianceImage]] = Field(None, title='Images for this appliance')
+ versions: Optional[List[ApplianceVersionV8]] = Field(None, title='Versions of the appliance')
+
+
+# ============================================================================
+# Discriminated Union
+# ============================================================================
+
+# Define the discriminated union type
+ApplianceUnion = Annotated[
+ Union[
+ Annotated[ApplianceV1_6, Tag('v1_6')],
+ Annotated[ApplianceV8, Tag('v8')],
+ ],
+ Discriminator('registry_version'),
+]
+"""
+Discriminated union type supporting both registry versions 1-6 and 8.
+Uses registry_version field to automatically route to correct model.
+"""
+
+# For type hints in function signatures
+Appliance = ApplianceUnion
+
+# Create a validator wrapper for convenience
+from pydantic import TypeAdapter
+
+_appliance_validator = TypeAdapter(ApplianceUnion)
+
+
+class ApplianceModel:
+ """
+ Wrapper class to provide model_validate() method for the Appliance union type.
+ This allows seamless usage of schemas.Appliance.model_validate() in existing code.
+ """
+
+ @staticmethod
+ def model_validate(data: dict) -> Union[ApplianceV1_6, ApplianceV8]:
+ """
+ Validate appliance data and return appropriate model instance.
+ Automatically routes to ApplianceV1_6 or ApplianceV8 based on registry_version.
+ """
+ return _appliance_validator.validate_python(data)
diff --git a/gns3server/schemas/controller/tokens.py b/gns3server/schemas/controller/tokens.py
index 2adc4b2bd..86c1a9377 100644
--- a/gns3server/schemas/controller/tokens.py
+++ b/gns3server/schemas/controller/tokens.py
@@ -27,3 +27,4 @@ class Token(BaseModel):
class TokenData(BaseModel):
username: Optional[str] = None
+ token_version: int = 0
diff --git a/gns3server/services/authentication.py b/gns3server/services/authentication.py
index c9bfaa565..9b9c6ffa7 100644
--- a/gns3server/services/authentication.py
+++ b/gns3server/services/authentication.py
@@ -45,12 +45,12 @@ class AuthService:
return bcrypt.checkpw(password=password.encode('utf-8'), hashed_password=hashed_password.encode('utf-8'))
- def create_access_token(self, username, secret_key: str = None, expires_in: int = 0) -> str:
+ def create_access_token(self, username, token_version: int = 0, secret_key: str = None, expires_in: int = 0) -> str:
if not expires_in:
expires_in = Config.instance().settings.Controller.jwt_access_token_expire_minutes
expire = datetime.now(timezone.utc) + timedelta(minutes=expires_in)
- to_encode = {"sub": username, "exp": expire}
+ to_encode = {"sub": username, "exp": expire, "ver": token_version}
if secret_key is None:
secret_key = Config.instance().settings.Controller.jwt_secret_key
if secret_key is None:
@@ -61,7 +61,7 @@ class AuthService:
encoded_jwt = jwt.encode({"alg": algorithm}, to_encode, key)
return encoded_jwt
- def get_username_from_token(self, token: str, secret_key: str = None) -> Optional[str]:
+ def get_token_data(self, token: str, secret_key: str = None) -> TokenData:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -80,7 +80,11 @@ class AuthService:
username: str = payload.claims.get("sub")
if username is None:
raise credentials_exception
- token_data = TokenData(username=username)
+ token_version: int = payload.claims.get("ver", 0)
+ token_data = TokenData(username=username, token_version=token_version)
except (JoseError, ValidationError, ValueError):
raise credentials_exception
- return token_data.username
+ return token_data
+
+ def get_username_from_token(self, token: str, secret_key: str = None) -> Optional[str]:
+ return self.get_token_data(token, secret_key).username
diff --git a/tests/api/routes/controller/test_users.py b/tests/api/routes/controller/test_users.py
index 4041f63bb..5db4a038c 100644
--- a/tests/api/routes/controller/test_users.py
+++ b/tests/api/routes/controller/test_users.py
@@ -247,6 +247,7 @@ class TestUserLogin:
key = OctKey.import_key(jwt_secret)
payload = jwt.decode(token, key, algorithms=["HS256"])
assert "sub" in payload.claims
+ assert "ver" in payload.claims
username = payload.claims.get("sub")
assert username == test_user.username
@@ -373,6 +374,117 @@ class TestUserMe:
assert response.status_code == status_code
+class TestLogout:
+
+ async def test_logout_returns_no_content(
+ self,
+ app: FastAPI,
+ unauthorized_client: AsyncClient,
+ test_user: User,
+ ) -> None:
+
+ # login to get a fresh token that includes token_version
+ credentials = {"username": test_user.username, "password": "user1_password"}
+ response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials)
+ assert response.status_code == status.HTTP_200_OK
+ token = response.json()["access_token"]
+
+ response = await unauthorized_client.post(
+ app.url_path_for("logout"),
+ headers={"Authorization": f"Bearer {token}"}
+ )
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+
+ async def test_token_is_rejected_after_logout(
+ self,
+ app: FastAPI,
+ unauthorized_client: AsyncClient,
+ test_user: User,
+ db_session: AsyncSession,
+ ) -> None:
+
+ # login and get a token
+ credentials = {"username": test_user.username, "password": "user1_password"}
+ response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials)
+ assert response.status_code == status.HTTP_200_OK
+ token = response.json()["access_token"]
+
+ # logout — increments token_version
+ await unauthorized_client.post(
+ app.url_path_for("logout"),
+ headers={"Authorization": f"Bearer {token}"}
+ )
+
+ # old token must now be rejected
+ response = await unauthorized_client.get(
+ app.url_path_for("get_logged_in_user"),
+ headers={"Authorization": f"Bearer {token}"}
+ )
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+ assert response.json()["message"] == f"Token has been revoked for '{test_user.username}'"
+
+ async def test_new_token_works_after_logout_and_relogin(
+ self,
+ app: FastAPI,
+ unauthorized_client: AsyncClient,
+ test_user: User,
+ ) -> None:
+
+ credentials = {"username": test_user.username, "password": "user1_password"}
+
+ # login, then logout
+ response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials)
+ old_token = response.json()["access_token"]
+ await unauthorized_client.post(
+ app.url_path_for("logout"),
+ headers={"Authorization": f"Bearer {old_token}"}
+ )
+
+ # login again to get a fresh token
+ response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials)
+ assert response.status_code == status.HTTP_200_OK
+ new_token = response.json()["access_token"]
+
+ # new token must work
+ response = await unauthorized_client.get(
+ app.url_path_for("get_logged_in_user"),
+ headers={"Authorization": f"Bearer {new_token}"}
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["username"] == test_user.username
+
+ async def test_stale_version_token_is_rejected(
+ self,
+ app: FastAPI,
+ unauthorized_client: AsyncClient,
+ test_user: User,
+ db_session: AsyncSession,
+ ) -> None:
+
+ # craft a token with ver=0 while the user's token_version is already higher
+ user_repo = UsersRepository(db_session)
+ user_in_db = await user_repo.get_user_by_username(test_user.username)
+
+ # force token_version ahead so any ver=0 token is stale
+ await user_repo.logout_user(user_in_db.user_id)
+
+ stale_token = auth_service.create_access_token(test_user.username, token_version=0)
+ response = await unauthorized_client.get(
+ app.url_path_for("get_logged_in_user"),
+ headers={"Authorization": f"Bearer {stale_token}"}
+ )
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+ async def test_logout_without_token_returns_unauthorized(
+ self,
+ app: FastAPI,
+ unauthorized_client: AsyncClient,
+ ) -> None:
+
+ response = await unauthorized_client.post(app.url_path_for("logout"))
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+
class TestSuperAdmin:
async def test_super_admin_exists(
diff --git a/tests/controller/test_node_port_name.py b/tests/controller/test_node_port_name.py
index 193f82ae4..26c054d23 100644
--- a/tests/controller/test_node_port_name.py
+++ b/tests/controller/test_node_port_name.py
@@ -133,6 +133,13 @@ def test_list_ports_port_name_format(node):
assert node.asdict()["ports"][1]["name"] == "eth0/0"
+def test_short_name_none():
+ """
+ Test short_name does not raise exception when name is None
+ """
+ assert EthernetPort(None, 0, 0, 0).short_name is None
+
+
def test_list_ports_adapters(node):
"""
List port using adapters properties