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