Merge pull request #2857 from yueguobin/feat/skills-device-topics

feat: appliance v8 install, copilot device integration, console terminal size and misc fixes
This commit is contained in:
Jeremy Grossmann 2026-08-21 20:53:28 +02:00 committed by GitHub
commit 770c898ec1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 4114 additions and 442 deletions

View File

@ -29,6 +29,9 @@
### Docker Container Stop Delay
- **[Docker Container Stop Delay](./docker-container-stop-delay.md)** - Some containers take ~5s to stop because they don't handle SIGTERM (AlpiNet, OstinatoWireshark)
### Device Console / Copilot Known Bugs
- **[XRd Console --More-- Pager Bug](./xrd-console-more-pager-bug.md)** - FIXED: root cause was our own 80x24 initial PTY geometry for the docker_exec console (the XR pager reads PTY rows, not `terminal length`); initial geometry is now 511x10000. Copilot reconnect-before-retry + session_log still open; `--More--` auto-answer kept as fallback design
### MCP Service
- **[MCP Service Design](./mcp-service-design.md)** - MCP (Model Context Protocol) service architecture using FastMCP with SSE transport, JWT auth, 29 tools across 5 domains
- **[MCP Tool Description Location](./mcp-tool-description-guide.md)** - Where to define MCP tool descriptions: in `@mcp.tool()` functions in `__init__.py`, not in `*_TOOLS` arrays

View File

@ -0,0 +1,72 @@
# XRd Console --More-- Pager Bug (PTY window size paging vs terminal length 0)
## Background
Copilot commands with long output consistently failed against XRd (IOS XRv 9000 container) nodes: `device_show_run` running `show ipv4 interface brief` always reported `netmiko_multiline (failed)` (even as a single command); short-output commands like `show ipv4 interface <iface>` worked fine.
Failure sequence (from the 2026-08-18 logs):
1. First failure: `ReadTimeout: Pattern not detected: 'RP/0/RP0/CPU0:ios\#'` — the command echo matched, but the prompt never appeared within 60s
2. The copilot's single-retry reused the same netmiko session (nornir caches it on the host object) with half-consumed output in the buffer → the second failure died earlier in `command_echo_read` — a follow-on effect of the dirty session, not an independent fault
## Root Cause (fully traced)
The XRd node uses the `docker_exec` console type (`gns3server/compute/docker/vendor_docker_vm.py`, `_LazyExecTelnetServer`): a telnet TCP server whose backend is a `docker exec` PTY. Three facts combine:
1. The XR pager (at least for table-engine/TABLAST-style commands) pages on the **PTY window size (TIOCGWINSZ)**, not the CLI-level `terminal length``show terminal` happily reports `Length: 0 lines` while output still pages
2. `_LazyExecTelnetServer.client_connected_hook` explicitly resized the exec PTY to **80×24** "before NAWS" (`await self._on_naws(80, 24)`) — the 24 rows were **our own initial geometry, not a Docker default**. Live test: `show ipv4 interface brief` paged after exactly 24 lines
3. netmiko's telnetlib never negotiates **NAWS**, so the initial geometry is never corrected for copilot/bare-telnet clients. Real NAWS clients resize to their own geometry via the existing `_on_naws``POST /exec/{id}/resize` wiring
### Dead ends (do not retry)
- `show ipv4 interface brief | no-more``% Invalid input detected`. `| no-more` is a **Junos** pipe modifier; IOS-XR does not have it
- `terminal length 0`: takes effect at the CLI layer, but this pager ignores it
- The XR CLI has no command to change PTY rows
### Related fact: paramiko vs docker_exec
paramiko (SSH) cannot connect to a `docker_exec` console — the client-side endpoint is a plain telnet server (`AsyncioTelnetServer`); only `console_type: ssh` (standard attach path, `AsyncioSSHServer`) speaks SSH. The copilot correctly uses netmiko `*_telnet` drivers (netmiko's vendored `_telnetlib`, stdlib-free on Python 3.13).
## Decision/Implementation
### Fix (implemented 2026-08-18, branch `feat/docker-exec-default-pty-geometry`)
Change the initial exec geometry in `vendor_docker_vm.py` `client_connected_hook` from 80×24 to **511×10000** (`await self._on_naws(511, 10000)`):
- Tall/wide default so CLIs that page on PTY rows never hit `--More--` for clients that never send NAWS (netmiko, bare telnet)
- Width 511 matches netmiko's `terminal width 511` convention
- Real NAWS clients still resize to their actual geometry right after connecting (existing `_on_naws` path unchanged)
- Test: `test_first_connect_sets_tall_default_pty_geometry` in `tests/compute/docker/test_vendor_docker_vm.py`
### Fallback design (NOT implemented — keep if the pager ever resurfaces on another console type)
Channel-level loop answering `--More--` in the copilot display tool for `cisco_xr*`: prompt regex breaks; **tail-anchored** `re.search(r"--More--\s*$", buf)` with a ~150ms quiet double-confirmation before `write_channel(" ")`. Never put `--More--` into netmiko's `expect_string` — expect `re.search`es accumulated output, so content containing "More" would false-trigger.
### Still-open copilot improvements (agreed, not yet implemented)
1. Reconnect before retry: `_run_all_device_configs_with_single_retry` (display tool) and the config tool's retry should `task.host.close_connection("netmiko")` first — retrying on a dirty session is doomed
2. `session_log_file` in hosts_data netmiko extras — the copilot has no session_log anywhere, which made this bug a guessing game
## Rationale
Resizing the PTY at exec creation attacks the root (geometry is fixed before the CLI outputs anything); it covers every consumer of the docker_exec console (copilot, MCP, bare telnet) with a one-line change, while the `--More--` auto-answer design remains as a generic fallback for consoles without a resize path.
## Related Files
- `gns3server/compute/docker/vendor_docker_vm.py``_LazyExecTelnetServer`: `_on_naws` (exec resize), `_create_exec` (Tty=True, TERM=xterm), `client_connected_hook` (initial geometry — the fix)
- `gns3server/compute/docker/docker_vm.py:1074-1087` — standard console path: NAWS → `containers/{cid}/resize`
- `gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py:284-341` — dirty-session retry (open item)
- `gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py` — hosts_data (session_log extras insertion point)
## Examples
Live console transcript (2026-08-17):
```
RP/0/RP0/CPU0:ios#show terminal
Length: 0 lines, Width: 511 columns <- CLI-layer setting in effect
RP/0/RP0/CPU0:ios#show ipv4 interface brief
(exactly 24 lines: timestamp + blank + header + 21 interface rows)
--More-- <- the 80x24 initial exec geometry was paging
```

View File

@ -85,7 +85,11 @@ bridge start {node_id}-{port}
```
Captures and marker signals are applied via the existing `_ubridge_apply_filters`
and `_ubridge_apply_markers` helpers from `BaseNode`.
and `_ubridge_apply_markers` helpers from `BaseNode`. The controller therefore allows
packet filters and traffic-insight markers on switch links (including switch-to-switch):
`ethernet_switch` is a marker/filter-capable node type, and the compute API exposes the
matching NIO-update and per-marker endpoints. The `ethernet_hub` — still Dynamips-hosted,
no uBridge — remains excluded.
### `remove_nio(port_number)`

View File

@ -31,17 +31,20 @@ they let a vendor NOS run as a first-class GNS3 Docker router node.
> schema changes, so existing Docker nodes (FRR, ipterm, …) are unaffected.
> `console_type: "docker_exec"` is added to the `ConsoleType` enum.
## The three environment knobs
## Environment knobs
All three are read from the node's `environment` field. Entries prefixed with
All are read from the node's `environment` field. Entries prefixed with
`GNS3_` are **not** forwarded into the container (existing GNS3 behaviour), so
they stay host-side configuration.
they stay host-side configuration. The console-relevant ones (the full vendor
set, incl. `GNS3_SHM_SIZE` / `GNS3_DEVICES` / `GNS3_MASK_UDEV` /
`GNS3_STOP_TIMEOUT`, is documented in [vendor-nos-xrd.md](./vendor-nos-xrd.md)):
| Variable | Purpose |
|----------|---------|
| `GNS3_SKIP_INIT=1` | Do **not** prepend `/gns3/init.sh` to the entrypoint. Vendor NOS images must run their own entrypoint (e.g. SR Linux's `sr_linux`); GNS3's init script (busybox bootstrap, `ifup`, eth wait) interferes with them. |
| `GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3` | Rename the injected interfaces in adapter order instead of the default `eth{N}`. SR Linux expects `mgmt0` + `e1-N`; without this it does not recognise its datapath. |
| `GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli` | Command run by the `docker_exec` console inside the container. |
| `GNS3_CONSOLE_RESIZE=0` | Ignore client-driven console resizes (WS terminal-size control frames / telnet NAWS) and keep the tall no-paging PTY geometry. Set for CLIs that page on the PTY window size (IOS-XR) — see [Terminal geometry](#terminal-geometry-and-size-forwarding). |
## Architecture: `VendorDockerVM` subclass
@ -87,18 +90,21 @@ Setting `console_type: "docker_exec"` makes the node's primary console port run
```mermaid
graph LR
A[Web UI xterm.js] -->|console WS| B[GNS3 Compute telnet server]
B -->|binary pty stream| C[Docker exec API]
C -->|Tty:true pty| D[sr_cli / vendor CLI]
A -.->|NAWS size| B
B -.->|POST exec/.../resize| C
A[Web UI xterm.js] -->|console WS: text frames| B[Controller forward]
B -->|WS: text + binary| C[GNS3 Compute telnet server]
C -->|binary pty stream| D[Docker exec API]
D -->|Tty:true pty| E[sr_cli / vendor CLI]
A -.->|binary control frame {"cols","rows"}| B
B -.-> C
C -.->|POST exec/.../resize| D
```
The console uses GNS3's **existing shared/broadcast telnet model**: a single
exec instance (one CLI session) is broadcast to every console client, exactly
like the primary console shares one PID 1. There is deliberately **no
per-client session isolation** — this matches how every other GNS3 console
behaves.
behaves. The PTY geometry is likewise shared: the last client resize wins
(see [Terminal geometry](#terminal-geometry-and-size-forwarding)).
### Implementation
@ -159,8 +165,11 @@ reconnect logic is unit-tested.
non-multiplexed bidirectional pty byte stream — no frame demux needed.
5. **NAWS → exec resize.** The telnet server runs with `naws=True`; the
`window_size_changed_callback` calls `POST exec/{eid}/resize?h=&w=` so the
TUI lays out for the xterm.js window size.
`window_size_changed_callback` (`_on_naws`, gated by
`GNS3_CONSOLE_RESIZE`) calls `POST exec/{eid}/resize?h=&w=` so the TUI
lays out for the client's window size. The internal `_resize_exec` path
(creation-time default, restore-on-idle) is not gated. See
[Terminal geometry](#terminal-geometry-and-size-forwarding).
6. **Binary passthrough + redraw.** `binary=True` so TUI escape sequences reach
xterm.js intact; `echo=False` (the pty echoes). On every client (re)connect
@ -168,9 +177,66 @@ reconnect logic is unit-tested.
screen for a previous client redraws for the new one (otherwise a
reconnect shows a blank screen until the next output).
### Terminal geometry and size forwarding
The exec PTY geometry is a shared resource with three consumers that want
different things:
- **Browser clients (xterm.js)** need the PTY to match their real window, or
TUI CLIs misrender and over-render (below).
- **Non-NAWS clients** (netmiko, bare telnet — no terminal-size negotiation)
need the PTY *tall*: CLIs that page on the PTY window size (the IOS-XR
pager ignores `terminal length 0`) park at `--More--` on a 24-row PTY.
- **Concurrent sessions share one exec** — one browser resize changes what
every attached client sees.
Resolution:
1. **Tall default.** The exec is created at 511×10000 (width 511 matches
netmiko's `terminal width 511` convention). Non-NAWS clients get no paging
and no hard wrapping.
2. **WS terminal-size forwarding.** Console WebSocket clients may send
**binary control frames** — UTF-8 JSON `{"cols": N, "rows": N}`
alongside text frames carrying terminal data (xterm.js's AttachAddon only
sends text, so binary is an unambiguous side channel; valid ranges are
cols 25000, rows 2100000, anything else is silently ignored). The
controller forwards binary frames (previously only text was forwarded —
and a binary frame would have crashed the old `receive_text` loop), and
the compute side turns them into a telnet NAWS subnegotiation for
telnet-based consoles (docker_exec included) or an asyncssh
`change_terminal_size` for SSH consoles
(`base_node.py` `start_websocket_console`).
3. **Races.** A size frame that arrives before/during the exec creation is
remembered and applied right after creation — it is **not** overwritten by
the tall default. When the **last** client disconnects the exec goes back
to 511×10000, so a later non-NAWS client attaching to the still-live exec
doesn't inherit a browser geometry and hit PTY-window paging.
4. **`GNS3_CONSOLE_RESIZE=0`** makes the console ignore client resizes
entirely (the tall default is then permanent). Set it for paging CLIs
where a browser resize would break concurrent netmiko sessions on the
shared exec — XRd, which is line-oriented and doesn't need browser
resizing at all.
**Why the browser must send its size — the SR Linux flicker.** `sr_cli` is a
prompt_toolkit TUI that anchors its layout with cursor-position requests
(CPR), which xterm.js answers. On a 10000-row PTY canvas the CPR-anchored
model conflicts with the winsize model, and every incremental render re-emits
the accumulated output: measured with a CPR-answering client, one `info`
command produces **~145 KB instead of ~60 KB** (~7× duplicated lines either
way — the CLI re-renders its output region as a scroll-append stream; that
part is inherent to `sr_cli` and identical outside GNS3, verified via manual
`docker exec`). The inflation is driven by **rows** (24/32 → normal, 10000 →
pathological, at any width) and is invisible without CPR answers — which is
why plain-telnet probes and real xterm.js sessions behaved so differently.
In the Web UI the excess renders as frequent full-screen clear/redraw — the
"flicker". With the browser's real size forwarded (rows ≈ 30), output volume
and rendering return to normal.
**File**: `gns3server/compute/base_node.py` — the console WebSocket guard now
allows `docker_exec` (alongside `telnet`/`ssh`), since the WS bridge connects to
the console TCP port exactly as it does for telnet.
the console TCP port exactly as it does for telnet. The same WS handler also
intercepts binary control frames and propagates client terminal sizes (see
[Terminal geometry](#terminal-geometry-and-size-forwarding)).
### Why earlier approaches failed (context)
@ -256,41 +322,46 @@ This is the one place where skipping init.sh changes behaviour beyond boot:
**nothing writes through to the host** — the container writes to its overlay
filesystem and the data is lost on stop.
The bridge (see init.sh lines 3552) has two parts:
init.sh (as the entrypoint) is safe because it runs **before** the
application: for each volume it seeds the host directory with the image's
original files on first start, then `mount --bind /gns3volumes<path> <path>`
bridges persistent storage into place.
`VendorDockerVM` cannot use that position (the NOS must own its entrypoint),
so the same persistence is established entirely **outside the container and
before it exists**:
```
host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always mounted)
│ init.sh: mount --bind
/etc/opt/srlinux (where the NOS writes)
create() 之前: host dir seeded from the image (docker create + docker cp, first time only)
create() 时: host ──Docker bind mount──▶ /etc/opt/srlinux (direct, at the real path)
启动: NOS native entrypoint — the persisted config is visible from the first process
```
`VendorDockerVM` replicates this for SKIP_INIT containers:
1. **`_prepare_volumes()`** — host-side, at `create()` time (after the image
is present, before the container is created). For each persistent volume
whose host directory lacks the `.gns3_perms` marker, a throwaway
`docker create` container (nothing executes) is used as a `docker cp -a`
source to seed the host directory with the image's original content. The
marker is written after the copy attempt — a volume that has it (every
node that ever started, on any GNS3 version) is **never re-seeded**, so
saved configuration is never overwritten with factory content.
1. **`_setup_skip_init_volumes()`** — runs once per start, right after the
container is up (`VendorDockerVM.start()`). For each persistent volume it
`docker exec`s a busybox script that:
- seeds the host directory with the container's original files on first
start (`cp -a` + `.gns3_perms` marker), exactly like init.sh;
- `mount --bind /gns3volumes<path> <path>` to bridge persistent storage
back to the in-container path — on subsequent starts the persisted data
replaces the fresh overlay content;
- restores the permissions recorded in `.gns3_perms` at the previous stop
(best-effort).
2. **`_mount_binds()` override** — the volume binds target the **real
in-container paths** (`/etc/opt/srlinux`) instead of `/gns3volumes<volume>`.
With the content seeded first, the image's files are never shadowed by an
empty mount, and the NOS sees its persisted configuration from the very
first process — no post-start mount pass that could race the NOS reading
its startup config (see "History: the exec-bridge race" below).
2. **Container-side `_fix_permissions()` override targeting `/gns3volumes`**
`DockerVM._fix_permissions` operates on the in-container paths
(`/etc/opt/srlinux`, …), which only resolve to persistent storage while
the `mount --bind` bridge is up; after a container restart the bridge is
gone and it would chown the overlay copy instead of the host files. It
also restarts an exited container just to chown. The override instead
runs the same busybox record/chmod/chown script **inside the container
(as root) on the `/gns3volumes<path>` paths** — the Docker bind-mount
targets, which exist for the whole container lifetime and need no bridge.
A stopped/exited container is **not** restarted: the pass is skipped and
the next start fixes ownership. It runs at start (so the controller can
read project files while the node runs) and at stop (for files written
during runtime).
3. **Container-side `_fix_permissions()` override** — runs the same busybox
record/chmod/chown script **inside the container (as root) on the volume
paths**. Because the volumes are Docker bind mounts created with the
container, the in-container paths resolve to the host files for the whole
container lifetime. A stopped/exited container is **not** restarted (the
base class would, just to chown; vendor NOS images are heavy to boot):
the pass is skipped and the next start fixes ownership. It runs at start
(so the controller can read project files while the node runs) and at
stop (for files written during runtime).
> The fix must run container-side: files written by the container are
> host-side root-owned, and an unprivileged GNS3 process cannot chown them
@ -309,9 +380,10 @@ the base class just created. Without `GNS3_SKIP_INIT` the mount is kept
| Phase | Normal Docker node | `VendorDockerVM` + `GNS3_SKIP_INIT` |
|-------|--------------------|--------------------------------------|
| start | init.sh seeds + bind-mounts + restores perms (in-container, before the app starts) | `docker exec` after start: seed + bind-mount + restore perms; then container-side chown on `/gns3volumes` |
| stop | container-side `_fix_permissions` on in-container paths (restarts an exited container) | container-side `_fix_permissions` on `/gns3volumes` paths (skips dead containers, no restart) |
| volume config | identical `_mount_binds` (host → `/gns3volumes<path>`) | identical |
| create | — | `_prepare_volumes()` seeds host dirs from the image (first create only); volumes bound **directly** at their real paths |
| start | init.sh seeds + bind-mounts + restores perms (in-container, before the app starts) | container-side `_fix_permissions` on the volume paths (skips dead containers, no restart) |
| stop | container-side `_fix_permissions` on in-container paths (restarts an exited container) | container-side `_fix_permissions` on the volume paths (skips dead containers, no restart) |
| volume config | `_mount_binds`: host → `/gns3volumes<path>` | `_mount_binds` override: host → `<path>` directly |
### Runtime ownership safety
@ -337,17 +409,28 @@ ever matters, drop the start-time pass and keep only the stop-time one
(standard behaviour — the trade-off is mid-run `Permission denied` in the
file browser, identical to regular Docker nodes).
### Boot-ordering caveat
### History: the exec-bridge race (fixed)
The volume bridge (`mount --bind`) is established **after** the vendor
entrypoint has started (there is no init.sh to do it before), so the NOS's
early boot reads the overlay copy of the volume paths — default image
content, not the persisted data. Whether the persisted config takes effect
depends on the NOS re-reading those files after the bridge is up (SR Linux's
daemons do re-read/write their managed files during boot, as observed).
Always verify the closed loop when adopting a new image: `save` a config →
stop the node → start it → confirm the config is actually applied, not just
present on the host.
The first SKIP_INIT implementation replicated init.sh's script **via
`docker exec` after the container started** instead of binding directly at
create time. That copied the mechanism but not the invariant that makes
init.sh safe — the entrypoint position, which guarantees the volume is in
place *before* the application runs. An exec-based bind runs **concurrently**
with the NOS boot, so whether the NOS reads its persisted config or the
overlay's factory copy was a timing race:
- a single node stop/start on an idle system won it (the exec landed ~1 s
in, SR Linux reads its startup config at ~24 s) — which is why the
round-trip "save → stop → start → config still there" passed;
- a server restart + project reload lost it (all nodes start concurrently,
the Docker API queue delays the execs by several seconds) — SR Linux
booted factory while the persisted `config.json` sat intact on the host;
- XRd was immune either way (systemd boots for tens of seconds before any
XR process touches `/xr-storage`), which is why the race was never seen
on it.
The direct-bind-at-create design removes the window entirely; there is no
ordering requirement left to verify when adopting a new NOS image.
## Troubleshooting
@ -406,49 +489,70 @@ present on the host.
root-owned files at runtime.
**9. Persistent volume empty on the host after `save` + stop**
- Ensure `GNS3_SKIP_INIT=1` is set (so the host-side bridge path is taken) and
the volume path is in `extra_volumes`; check the compute log for
`Volume '<path>' bound to persistent storage`.
- Ensure `GNS3_SKIP_INIT=1` is set (so the direct-bind path is taken) and
the volume path is in `extra_volumes`; check that the host directory
carries the `.gns3_perms` marker (written at create-time seeding) and the
compute log for `Seeded persistent volume`.
**10. Persisted config present on the host but not applied after restart**
- The volume bridge is established after the NOS has booted (see
*Boot-ordering caveat*); the NOS may have already loaded the overlay's
default config into memory. Verify with a visible change (hostname,
interface description): `save` → stop → start → check the change took
effect. If it does not, the image needs the bridge earlier (a
vendor-specific entrypoint wrapper, not covered by this prototype).
- On builds since the direct-bind rework this should not happen: the volume
is in place before the first process. If you see it, confirm the server
build includes the rework (older builds established the bind via a
post-start `docker exec` that could lose the race against the NOS reading
its startup config — see *History: the exec-bridge race*).
**11. Web console flickers (full-screen clear/redraw) on every command**
- The PTY is stuck at the tall 511×10000 default while a CPR-answering client
is attached — see
[Terminal geometry](#terminal-geometry-and-size-forwarding). Check that the
Web UI actually sends the binary size control frames on connect/resize
(F12 → the console WS should show outgoing binary frames), and that the
server is new enough to forward them (the controller used to forward text
frames only). A client that never negotiates/forwards size (old Web UI,
bare telnet without NAWS) cannot trigger the fix — but also never answers
CPR, so it doesn't flicker either.
- The much milder per-keystroke/5 s cursor toggles (`\e[?25l…\e[?25h`) from
the TUI are normal and not this bug.
## Limitations
1. **Shared session (broadcast).** All console clients share one CLI session
and can see each other's input — identical to GNS3's existing primary
console model. There is no per-client independent session.
console model. There is no per-client independent session. The PTY
geometry is shared too (last resize wins): two browsers of different sizes
disagree harmlessly, but a browser on a *paging* CLI needs
`GNS3_CONSOLE_RESIZE=0` to stop resizing on behalf of concurrent netmiko
sessions (see [Terminal geometry](#terminal-geometry-and-size-forwarding)).
2. **`reset_console` not wired.** The console-reset action only handles
`telnet`/`ssh`; it is a no-op for `docker_exec` (non-blocking; reconnect
works fine).
3. **Prototype knobs.** `GNS3_SKIP_INIT` / `GNS3_INTERFACE_NAMES` /
`GNS3_CONSOLE_CMD` are environment-driven; they are not yet first-class node
schema fields and are not declared in the appliance (`gns3a`) schema.
`GNS3_CONSOLE_CMD` / `GNS3_CONSOLE_RESIZE` are environment-driven; they are
not yet first-class node schema fields and are not declared in the
appliance (`gns3a`) schema.
4. **Rootful-Docker assumption** (`UsernsMode: host`, set for all GNS3
Docker nodes) so the container-side chown acts on the host files' real
uid/gid (see the volume-persistence section).
5. **Post-boot volume bridge.** The bind-mount bridge is established after the
vendor entrypoint has started (init.sh would do it before). A NOS that
strictly requires its persisted files at its very first read may need a
different boot arrangement (see *Boot-ordering caveat*).
5. **Docker CLI dependency.** Volume seeding shells out to the `docker`
binary (`docker create` + `docker cp` + `docker rm`) at create time —
the same dependency the permission passes already have.
## References
- `gns3server/compute/docker/vendor_docker_vm.py``VendorDockerVM`:
`_start_docker_exec_console`, `_LazyExecTelnetServer`,
`_setup_skip_init_volumes`, container-side `_fix_permissions` on
`/gns3volumes`, `start()`.
`_prepare_volumes` (host-side seeding), direct volume binds in
`_mount_binds`, container-side `_fix_permissions`, `start()`.
- `gns3server/compute/docker/docker_vm.py``DockerVM` extension hooks
(`_prepare_init_and_interface_env`, `_start_console_server`,
`_get_container_ifname`, `_cleanup_console_resources`).
- `gns3server/compute/docker/__init__.py``Docker._select_node_class` /
`create_node` factory.
- `gns3server/compute/base_node.py` — console WebSocket guard.
- `gns3server/compute/base_node.py` — console WebSocket guard; binary
terminal-size control frames → NAWS / asyncssh resize
(`start_websocket_console`).
- `gns3server/api/routes/controller/nodes.py` — console WS forwarding
(text and binary frames).
- `gns3server/schemas/common.py``ConsoleType.docker_exec`.
- containerlab `nodes/srl/srl.go` — reference for SR Linux launch command and
interface naming.
@ -457,6 +561,8 @@ present on the host.
| Version | Date | Changes |
|---------|------|---------|
| 1.7 | 2026-08-22 | SKIP_INIT volume persistence rebuilt: host-side seeding at create time (`docker create` + `docker cp -a`, marker-gated so saved config is never overwritten) and direct bind mounts at the real in-container paths replace the post-start `docker exec` bridge. Root cause: the exec bridge raced the NOS reading its startup config — SR Linux read `config.json` at ~24 s and booted factory whenever concurrent node starts (server restart + project reload) delayed the exec past that point, while single-node stop/start and XRd (systemd touches `/xr-storage` tens of seconds in) never lost the race. New `_prepare_volumes` hook on `DockerVM`; `_fix_permissions` now targets the volume paths directly. |
| 1.6 | 2026-08-20 | Terminal geometry and size forwarding: WS binary control frames `{"cols","rows"}` → NAWS / asyncssh resize (controller now forwards binary frames; compute intercepts them); tall 511×10000 default kept for non-NAWS clients, applied post-creation and restored on last disconnect (client size racing exec creation wins over the default); new `GNS3_CONSOLE_RESIZE=0` knob for paging CLIs (XRd) where a browser resize would break concurrent netmiko sessions on the shared exec; documented the SR Linux flicker root cause (tall rows × CPR-answering client → ~2.4× re-emitted output; rows-driven, width-independent). |
| 1.5 | 2026-08-13 | Add appliance (`gns3a`) packaging section: 35-adapter full-chassis design, the three server-side schema fixes (DockerConsoleType, ApplianceV1_6.custom_adapters, extra_volumes passthrough), and the symbol-theme caveat (any `:/symbols/` symbol is rewritten to the category default at load). |
| 1.4 | 2026-08-13 | Reconnect fix: drop the while-true wrapper (it restarted the CLI with no client to answer CPR → blank screen on reconnect); the exec is now recreated on connect when the upstream has died. `_LazyExecTelnetServer` extracted to module level and unit-tested. |
| 1.3 | 2026-08-12 | Document runtime ownership safety (root processes, self-healing daemons, ACL evidence for SR Linux), the boot-ordering caveat (bridge after boot → verify save/stop/start closed loop), and troubleshooting #10. |

View File

@ -97,8 +97,8 @@ IOU runs a single `IOL-BRIDGE` per node shared by every interface, so `bridge`+`
identical across that node's links. uBridge keeps a separate filter list **per port
(bay/unit)** within the bridge, so each interface gets its own `global-{name}` filter, its own
pcap file, and its own `link=`. The shared bridge name is irrelevant to attribution. Other
capable node types (`qemu`, `docker`, `vpcs`, `cloud`) already use one bridge per link; `link`
applies uniformly to all of them.
capable node types (`qemu`, `docker`, `vpcs`, `cloud`, `ethernet_switch`) already use one
bridge per link (the switch's per-port relay); `link` applies uniformly to all of them.
## Direction
@ -137,7 +137,7 @@ pass `capture_node_id` on marker **create**:
```
The value must be one of the link's two endpoints and a marker-capable type (`vpcs`, `qemu`,
`docker`, `iou`, `dynamips`, `cloud`); any other id is rejected with `409`. Omit it to keep
`docker`, `iou`, `dynamips`, `cloud`, `ethernet_switch`); any other id is rejected with `409`. Omit it to keep
the auto-pick. The chosen id is echoed back as `capture_node_id` in the marker entry and in
each `MARK` signal's `node=<id>`, so the Web UI always knows the observer regardless of who
picked it.
@ -357,7 +357,9 @@ direction relative to the capture node; see [Direction](#direction).
runs one `tcpdump -d` rather than *N*. (uBridge still runs `pcap_compile` itself at
install time, so an invalid expression can never slip through.)
- **Supported node types.** A marker needs a uBridge bridge: `vpcs`, `qemu`, `docker`,
`iou`, `dynamips`, `cloud` (one capable endpoint suffices). Types without a uBridge are
`iou`, `dynamips`, `cloud`, `ethernet_switch` (one capable endpoint suffices). The
`ethernet_switch` hosts markers on its per-port uBridge relays (brctl backend); the
`ethernet_hub` is still Dynamips-hosted and has no uBridge. Types without a uBridge are
silently skipped by the inheritance fan-out. IOU uses one shared `IOL-BRIDGE` per node but
keeps filters, pcap files, and `link=` ids per port, so multi-interface nodes are handled
(see [Per-link attribution](#per-link-attribution)).

View File

@ -41,7 +41,7 @@ graph TB
MASK["GNS3_MASK_UDEV → /dev/null binds"]
HOSTCFG["ShmSize / Devices"]
CFGINJ["extra_configs → RO single-file bind"]
VBRIDGE["VendorDockerVM volume bridge"]
VBRIDGE["VendorDockerVM volume seeding + direct binds"]
HOSTCHK["host-readiness check (read-only)"]
end
subgraph Container["XRd container"]
@ -100,6 +100,7 @@ Note: "journal corrupted" messages with varying machine-IDs come from the
```
GNS3_SKIP_INIT=1
GNS3_CONSOLE_CMD=/pkg/bin/xr_cli.sh
GNS3_CONSOLE_RESIZE=0
GNS3_MASK_UDEV=1
GNS3_SHM_SIZE=1024
GNS3_DEVICES=/dev/fuse
@ -109,6 +110,15 @@ XR_MGMT_INTERFACES=linux:eth0,xr_name=Mg0/RP0/CPU0/0,chksum,snoop_v4,snoop_v6
XR_INTERFACES=linux:eth1,xr_name=Gi0/0/0/0;linux:eth2,xr_name=Gi0/0/0/1;...
```
`GNS3_CONSOLE_RESIZE=0` (console geometry lock): the XR pager pages on the
PTY window size and ignores `terminal length 0`, so the exec PTY must stay at
the tall no-paging default for **every** client. The docker_exec console is a
single shared exec — one browser's terminal-size resize (WS control frames →
NAWS) would change the geometry concurrent netmiko/copilot sessions see and
bring `--More--` back. XRd's CLI is line-oriented, so browsers lose nothing
by not resizing. See
[docker-exec-console.md](./docker-exec-console.md#terminal-geometry-and-size-forwarding).
XRd-specific gotchas (image-side, not GNS3):
- Management interface xr_name is **`Mg0/RP0/CPU0/0`** (short prefix, `CPU0`
@ -148,11 +158,12 @@ sequenceDiagram
participant X as XRd container
U->>S: create node from template
S->>S: parse GNS3_* env host-side
S->>D: container create (ShmSize, Devices, /dev/null binds, firstboot.cfg RO bind)
S->>D: seed volume host dirs from image (docker create + cp, first time only)
S->>D: container create (ShmSize, Devices, /dev/null binds, firstboot.cfg RO bind, volumes bound directly at /xr-storage*)
U->>S: start
S->>X: container start (native entrypoint /usr/sbin/init)
Note over X: systemd boots; udevd + udevadm masked → host untouched
S->>X: docker exec volume bridge (container's own chown)
S->>X: docker exec permission fix (container's own chown)
U->>S: open console
S->>X: docker exec pty: /pkg/bin/xr_cli.sh
X-->>U: IOS XR CLI (first boot: apply /firstboot.cfg, save to /xr-storage-shadow)
@ -188,7 +199,7 @@ sequenceDiagram
- `gns3server/compute/docker/docker_vm.py` — HostConfig env injection,
`_UDEV_UNITS`/`_UDEVADM_PATHS`, `extra_configs` binds, `_format_devices()`
- `gns3server/compute/docker/vendor_docker_vm.py` — vendor path, volume
bridge, container-chown
seeding + direct binds, container-chown
- `gns3server/compute/docker/__init__.py``_check_host_readiness()`
- `gns3server/schemas/common.py``ExtraConfig`
- `gns3server/db/models/templates.py` + `db_migrations/` — persistence
@ -201,6 +212,8 @@ sequenceDiagram
| Version | Date | Changes |
|---------|------|---------|
| 1.6 | 2026-08-21 | SKIP_INIT volume persistence rebuilt: host-side seeding at create time (`docker create` + `docker cp`) and direct bind mounts at the real in-container paths replace the post-start `docker exec` bridge, which raced the NOS reading its startup config (visible on SR Linux: factory boot after a server restart + project reload; XRd was immune only because systemd touches `/xr-storage` tens of seconds in). No behaviour change for XRd beyond the race removal. |
| 1.5 | 2026-08-20 | Appliance env gains `GNS3_CONSOLE_RESIZE=0`: client-driven console resizes are ignored so the shared exec PTY stays at the tall no-paging geometry for concurrent netmiko/copilot sessions (browsers included). |
| 1.4 | 2026-08-15 | Code-review hardening: stop-query HTTP timeout scales with `GNS3_STOP_TIMEOUT` (values >300 s no longer abort); overlapping mask/config bind targets deduplicated (Docker "Duplicate mount point"); `ExtraConfig.target` validated at save time and directory forms rejected; host-readiness check no longer aborts on one unreadable `/proc/sys` key; base env parser strips trailing commas; vendor env knobs re-parsed on create (PUT environment takes effect); graceful stop limited to explicit user stop (delete/update/close keep the immediate kill); extra_configs under a persisted volume warns. |
| 1.3 | 2026-08-14 | Persistence corrected: XR's live data layer is `/xr-storage` (the image's symlink farm is materialized into real directories at bootstrap; `/xr-storage-shadow` is a pristine spare) — the appliance persists both. Vendor containers now stop gracefully (SIGTERM + `GNS3_STOP_TIMEOUT` grace, default 60 s) instead of being SIGKILLed on the spot. |
| 1.2 | 2026-08-14 | Link UDP self-loop root-caused to a port-allocation race and fixed — see [link-udp-self-loop](../bugs/link-udp-self-loop.md) (now marked Fixed). |

View File

@ -14,7 +14,7 @@ GNS3 Copilot loads all skills, prompts, and security configurations from an exte
The repository provides:
- **Injection skills** (39 categories): Network fault scenarios for troubleshooting practice
- **Device skills**: Device-specific command knowledge (VPCS, etc.)
- **Device skills**: Device-specific command knowledge (VPCS, etc.) — large devices split into per-protocol **topics**
- **Feature skills**: Topology planning, network design
- **System prompts**: Agent personality and behavior definitions
- **Forbidden commands**: Security rules for command filtering
@ -24,7 +24,7 @@ The repository provides:
```mermaid
graph TD
subgraph "GNS3-Skills Repository"
YAML[injection/*.yaml<br/>device/*.yaml<br/>feature/*.yaml]
YAML[injection/*.yaml<br/>device/*.yaml + device/*/*.yaml<br/>feature/*.yaml]
MD[prompts/*.md]
CFG[config/forbidden_commands.txt]
end
@ -56,7 +56,11 @@ GNS3-Skills/
│ ├── vlan_issues.yaml
│ └── ...
├── device/ # Device-specific skills
│ └── vpcs.yaml
│ ├── vpcs.yaml # small devices: one single file
│ └── frr/ # large devices: split per protocol topic
│ ├── _base.yaml # device-level skill (console model, notes, aliases)
│ ├── ospf.yaml # topic file (merged under "topics" at load time)
│ └── bgp.yaml
├── feature/ # Feature skills
│ └── topology_planner.yaml
├── prompts/ # System prompts (Markdown)
@ -68,6 +72,26 @@ GNS3-Skills/
└── forbidden_commands.txt
```
## Device Topics
A device with knowledge for many protocols would grow one YAML file indefinitely. Such devices use a split layout instead: `device/<device>/_base.yaml` holds the device-level skill, and one file per protocol topic (`ospf.yaml`, `bgp.yaml`, ...) holds its commands and troubleshooting entries. The loader merges them into a single `SKILLS_REGISTRY` entry:
```
SKILLS_REGISTRY["frr_vtysh"] = { ..._base.yaml..., "topics": { "ospf": {...}, "bgp": {...} } }
```
Topic files must declare `device_type` (matching their `_base.yaml`), `topic` and `name`; the CI validator in the skills repository enforces this.
The `device_skills` tool exposes a three-step drill-down (mirroring `injection_skills`'s list → index → issue pattern):
```json
{"action": "list"}
{"device_type": "frr_vtysh", "detail": "index"}
{"device_type": "frr_vtysh", "topic": "bgp"}
```
Topic bodies are only served on an explicit `topic` request — every other detail level returns a topic index — so adding topics to a device does not grow the token cost of device-level lookups.
## Configuration
Skills repository settings are configured in `gns3_server.conf` under the `[Server]` section:

View File

@ -1,32 +1,32 @@
<!--
SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information.
-->
<!DOCTYPE html>
<html>
<head>
<title>GNS3 controller API - ReDoc</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link type="text/css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
<link rel="shortcut icon" href="https://fastapi.tiangolo.com/img/favicon.png">
<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
<title>GNS3 controller API - Swagger UI</title>
</head>
<body>
<redoc spec-url="openapi.json"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
<div id="swagger-ui">
</div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<!-- `SwaggerUIBundle` is now available on the page -->
<script>
const ui = SwaggerUIBundle({
url: 'openapi.json',
"dom_id": "#swagger-ui",
"layout": "BaseLayout",
"deepLinking": true,
"showExtensions": true,
"showCommonExtensions": true,
oauth2RedirectUrl: window.location.origin + '/docs/oauth2-redirect',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
})
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -1,35 +1,31 @@
<!--
SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information.
-->
<!DOCTYPE html>
<html>
<head>
<link type="text/css" rel="stylesheet" href="/static/swagger-ui.css">
<title>GNS3 controller API - ReDoc</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<link rel="shortcut icon" href="https://fastapi.tiangolo.com/img/favicon.png">
<title>GNS3 controller API - Swagger UI</title>
<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<div id="swagger-ui">
</div>
<script src="/static/swagger-ui-bundle.js"></script>
<!-- `SwaggerUIBundle` is now available on the page -->
<script>
const ui = SwaggerUIBundle({
url: 'openapi.json',
oauth2RedirectUrl: window.location.origin + '/docs/oauth2-redirect',
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout",
deepLinking: true,
showExtensions: true,
showCommonExtensions: true
})
</script>
<noscript>
ReDoc requires Javascript to function. Please enable it to browse the documentation.
</noscript>
<redoc spec-url="openapi.json"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js"> </script>
</body>
</html>

View File

@ -535,6 +535,8 @@ class Gns3Connector:
- `tags` (list): List of tags for the template (e.g.,
["device_type:cisco_ios_telnet", "platform:cisco_ios"])
- `netmiko_device_type` (str): Netmiko device type (e.g. "cisco_ios_telnet");
preferred over the device_type:<type> tag
- Any other template attributes supported by GNS3 API
"""
# Get existing template
@ -571,6 +573,8 @@ class Gns3Connector:
- `tags` (list): List of tags for the template (e.g.,
["device_type:cisco_ios_telnet", "platform:cisco_ios"])
- `netmiko_device_type` (str): Netmiko device type (e.g. "cisco_ios_telnet");
preferred over the device_type:<type> tag
- Any other template attributes supported by GNS3 API
**Example:**
@ -579,7 +583,8 @@ class Gns3Connector:
>>> connector.create_template(
... name="cisco_router",
... template_type="dynamips",
... tags=["device_type:cisco_ios_telnet", "platform:cisco_ios"]
... tags=["device_type:cisco_ios_telnet", "platform:cisco_ios"],
... netmiko_device_type="cisco_ios_telnet"
... )
```
"""
@ -1366,6 +1371,9 @@ class Node:
template_id: str | None = None
properties: Any | None = None
tags: list[str] | None = None
netmiko_device_type: str | None = None
default_username: str | None = None
default_password: str | None = None
template: str | None = None
links: list[Link] = field(default_factory=list, repr=False)
@ -2482,6 +2490,9 @@ class Project:
"x": _n.x,
"y": _n.y,
"tags": _n.tags if _n.tags else [],
"netmiko_device_type": _n.netmiko_device_type,
"default_username": _n.default_username,
"default_password": _n.default_password,
}
}
)

View File

@ -100,6 +100,12 @@ class SkillsLoader:
"""
Load all device skills from YAML files.
Supports two layouts:
- Single file: device/<device>.yaml
- Split directory: device/<device>/_base.yaml + device/<device>/<topic>.yaml
(topic files are merged into the base skill under "topics",
keyed by their "topic" field)
Returns:
Dictionary mapping skill keys to skill definitions
"""
@ -107,33 +113,110 @@ class SkillsLoader:
logger.error("PyYAML is not installed. Cannot load skills from YAML.")
return {}
skills = {}
skills: Dict[str, Dict[str, Any]] = {}
device_dir = self.skills_dir / "device"
if not device_dir.exists():
logger.warning(f"Device skills directory not found: {device_dir}")
return {}
for yaml_file in device_dir.glob("*.yaml"):
try:
skill_data = self._load_yaml(yaml_file)
if not skill_data:
logger.warning(f"Skipping empty YAML file: {yaml_file}")
continue
# Use device_type from YAML content as the key
# Fallback to filename stem if device_type not present
skill_key = skill_data.get("device_type") if isinstance(skill_data, dict) else None
if not skill_key:
skill_key = yaml_file.stem
logger.warning(f"No device_type in {yaml_file}, using filename '{skill_key}' as key")
skills[skill_key] = skill_data
logger.debug(f"Loaded device skill: {skill_key} from {yaml_file}")
except Exception as e:
logger.error(f"Failed to load skill from {yaml_file}: {e}")
for entry in sorted(device_dir.iterdir()):
if entry.is_file() and entry.suffix == ".yaml":
self._load_single_device_skill(skills, entry)
elif entry.is_dir():
self._load_split_device_skill(skills, entry)
logger.debug(f"Loaded {len(skills)} device skills from device directory")
return skills
def _load_single_device_skill(self, skills: Dict[str, Dict[str, Any]], yaml_file: Path) -> None:
"""
Load a single-file device skill into the skills dictionary.
"""
try:
skill_data = self._load_yaml(yaml_file)
if not skill_data:
logger.warning(f"Skipping empty YAML file: {yaml_file}")
return
# Use device_type from YAML content as the key
# Fallback to filename stem if device_type not present
skill_key = skill_data.get("device_type")
if not skill_key:
skill_key = yaml_file.stem
logger.warning(f"No device_type in {yaml_file}, using filename '{skill_key}' as key")
skills[skill_key] = skill_data
logger.debug(f"Loaded device skill: {skill_key} from {yaml_file}")
except Exception as e:
logger.error(f"Failed to load skill from {yaml_file}: {e}")
def _load_split_device_skill(self, skills: Dict[str, Dict[str, Any]], device_path: Path) -> None:
"""
Load a split device skill (directory with _base.yaml + topic files).
The base file provides the device-level skill; every other YAML file
in the directory is a protocol topic merged under "topics".
"""
base_file = device_path / "_base.yaml"
if not base_file.exists():
logger.error(f"No _base.yaml in device directory: {device_path}, skipping")
return
try:
base_data = self._load_yaml(base_file)
except Exception as e:
logger.error(f"Failed to load skill from {base_file}: {e}")
return
if not base_data:
logger.warning(f"Skipping empty YAML file: {base_file}")
return
skill_key = base_data.get("device_type")
if not skill_key:
skill_key = device_path.name
logger.warning(f"No device_type in {base_file}, using directory name '{skill_key}' as key")
# Seed topics from the base file (if any), then merge topic files
base_topics = base_data.get("topics")
topics: Dict[str, Any] = dict(base_topics) if isinstance(base_topics, dict) else {}
for yaml_file in sorted(device_path.glob("*.yaml")):
if yaml_file.name == "_base.yaml":
continue
try:
topic_data = self._load_yaml(yaml_file)
if not topic_data:
logger.warning(f"Skipping empty YAML file: {yaml_file}")
continue
topic_device_type = topic_data.pop("device_type", None)
if topic_device_type is not None and topic_device_type != skill_key:
logger.error(
f"device_type mismatch in {yaml_file}: '{topic_device_type}' "
f"!= base '{skill_key}', skipping topic"
)
continue
topic_key = topic_data.pop("topic", None)
if not topic_key:
topic_key = yaml_file.stem
logger.warning(f"No 'topic' field in {yaml_file}, using filename '{topic_key}'")
if topic_key in topics:
logger.warning(f"Duplicate topic '{topic_key}' in {device_path.name} (from {yaml_file}), overwriting")
# category/topics belong to the base skill only
topic_data.pop("category", None)
topic_data.pop("topics", None)
topics[topic_key] = topic_data
logger.debug(f"Loaded device topic: {skill_key}/{topic_key} from {yaml_file}")
except Exception as e:
logger.error(f"Failed to load topic from {yaml_file}: {e}")
if topics:
base_data["topics"] = topics
skills[skill_key] = base_data
logger.debug(f"Loaded device skill: {skill_key} from {device_path} ({len(topics)} topics)")
def load_feature_skills(self) -> Dict[str, Dict[str, Any]]:
"""
Load all feature skills from YAML files.

View File

@ -221,11 +221,18 @@ class SkillsManager:
logger.warning("No injection skills loaded, keeping existing skills")
return False
# Validate injection skills
# Validate injection skills (drop invalid ones before merging)
valid_injection_skills = {}
for skill_key, skill_data in new_injection_skills.items():
if not self.loader.validate_skill_format(skill_data):
if self.loader.validate_skill_format(skill_data):
valid_injection_skills[skill_key] = skill_data
else:
logger.error(f"Invalid skill format for {skill_key}, skipping")
continue
if not valid_injection_skills:
logger.warning("No valid injection skills loaded, keeping existing skills")
return False
new_injection_skills = valid_injection_skills
# Load new device skills from YAML files
new_device_skills = self.loader.load_device_skills()

View File

@ -373,6 +373,7 @@ def get_skill(
category: str | None = None,
detail: str = "full",
issue: str | None = None,
topic: str | None = None,
) -> dict[str, Any]:
"""
Get skill by device_type, with configurable detail level.
@ -382,6 +383,9 @@ def get_skill(
category: Optional category filter
detail: Detail level - "index" (names only), "summary" (+desc/sev/diff), "full" (all)
issue: Optional specific issue key to retrieve
topic: Optional protocol topic to retrieve (split devices only).
Topic bodies are NEVER included without an explicit topic
request - all other detail levels return a topic index.
Returns:
Skill dictionary (detail varies by level), or error dict
@ -412,6 +416,28 @@ def get_skill(
],
}
topics = skill.get("topics", {})
# Single topic lookup (topic bodies stay out of every other response)
if topic:
topic_data = topics.get(topic)
if not topic_data:
for key, data in topics.items():
if key.lower() == topic.lower():
topic_data = data
topic = key
break
if not topic_data:
return {
"error": f"Unknown topic '{topic}' in {device_type}",
"available_topics": list(topics.keys()),
}
return {
"device_type": device_type,
"skill_name": skill.get("name"),
"topic": {topic: topic_data},
}
issues = skill.get("issues", {})
# Single issue lookup (most token-efficient)
@ -429,17 +455,20 @@ def get_skill(
}
if detail == "index":
# Minimal: only issue keys and names (90%+ token savings)
return {
# Minimal: only issue/topic keys and names (90%+ token savings)
result = {
"device_type": device_type,
"name": skill.get("name"),
"description": skill.get("description"),
"issues": {k: v["name"] for k, v in issues.items()},
}
if topics:
result["topics"] = {k: v.get("name", k) for k, v in topics.items()}
return result
if detail == "summary":
# Moderate: names + description + severity + difficulty
return {
result = {
"device_type": device_type,
"name": skill.get("name"),
"description": skill.get("description"),
@ -453,14 +482,22 @@ def get_skill(
for k, v in issues.items()
},
}
if topics:
result["topics"] = {
k: {"name": v.get("name", k), "description": v.get("description", "")}
for k, v in topics.items()
}
return result
# Full detail (original behavior)
result = dict(skill)
# Full detail: topic bodies are replaced by the topic index
result = {k: v for k, v in skill.items() if k != "topics"}
result["device_type"] = device_type
if topics:
result["topics"] = {k: v.get("name", k) for k, v in topics.items()}
return result
def list_available_skills(category: str | None = None) -> list[dict[str, str]]:
def list_available_skills(category: str | None = None) -> list[dict[str, Any]]:
"""List all available device/feature skills, optionally filtered by category."""
skills = []
for did, skill in SKILLS_REGISTRY.items():
@ -470,12 +507,14 @@ def list_available_skills(category: str | None = None) -> list[dict[str, str]]:
"device_type": did,
"name": skill.get("name", did),
"category": skill.get("category"),
"topic_count": len(skill.get("topics", {})),
})
else:
skills.append({
"device_type": did,
"name": skill.get("name", did),
"category": skill.get("category"),
"topic_count": len(skill.get("topics", {})),
})
return skills
@ -642,15 +681,21 @@ class DeviceSkillsTool(BaseTool):
Provides access to device command knowledge (VPCS), topology planning, etc.
For fault injection skills, use the injection_skills tool.
INPUT FORMAT (JSON string):
{
"action": "get", # "get" (default) or "list"
"device_type": "gns3_vpcs_telnet", # Required for action="get"
"detail": "full" # "full" (default) for complete skill information
}
TOKEN-EFFICIENT USAGE:
1. List devices: {"action": "list"}
2. List topics of a device: {"device_type": "frr_vtysh", "detail": "index"}
3. Get ONE protocol topic (devices with topics): {"device_type": "frr_vtysh", "topic": "bgp"}
4. Devices without topics: {"device_type": "gns3_vpcs_telnet"}
For action="list":
{"action": "list"} # Lists all available device/feature skills
Topic bodies are NEVER returned without an explicit "topic" - fetching a
device without one only returns its base skill plus the topic index, so
always request the specific protocol topic before configuring it.
PARAMETERS:
- action: "list" or "get" (default "get")
- device_type: Required for action="get" (e.g., "frr_vtysh")
- topic: Protocol topic key from the topic index (e.g., "ospf", "bgp")
- detail: "index" | "summary" | "full" (default "full")
"""
def _run(
@ -693,8 +738,9 @@ class DeviceSkillsTool(BaseTool):
category = params.get("category")
detail = params.get("detail", "full")
issue = params.get("issue")
topic = params.get("topic")
skill = get_skill(device_type, category, detail=detail, issue=issue)
skill = get_skill(device_type, category, detail=detail, issue=issue, topic=topic)
return json.dumps(skill, ensure_ascii=False, indent=2)

View File

@ -62,7 +62,7 @@ def get_device_ports_from_topology(
"groups": ["network_devices"], # For inheriting shared settings
"connection_options": {
"netmiko": {
"extras": {"device_type": "huawei_telnet"} # Extracted from tags
"extras": {"device_type": "huawei_telnet"} # netmiko_device_type field, tag fallback
}
}
}
@ -102,18 +102,21 @@ def get_device_ports_from_topology(
logger.warning("Device '%s' missing console_port", device_name)
continue
# Extract device_type and platform from tags
device_type = None
# Extract device_type and platform.
# Precedence: the netmiko_device_type field (node/template/appliance
# level, set in GNS3 server >= 3.x) wins over the device_type:<type>
# tag, which remains as a fallback.
device_type = node_info.get("netmiko_device_type")
platform = None
tags = node_info.get("tags", [])
for tag in tags:
if tag.startswith("device_type:"):
if tag.startswith("device_type:") and device_type is None:
device_type = tag.split(":", 1)[1].strip()
elif tag.startswith("platform:"):
platform = tag.split(":", 1)[1].strip()
# Return error if device_type not found in tags
# Return error if device_type not found anywhere
# Using a default would cause command execution errors
if device_type is None:
tested_device_types = (
@ -121,8 +124,9 @@ def get_device_ports_from_topology(
"gns3_ruijie_telnet (custom Ruijie)"
)
error_msg = (
f"Device '{device_name}': device_type tag not found. "
f"Please add 'device_type:<type>' tag to this device in GNS3. "
f"Device '{device_name}': no device type found. "
f"Set the template/node 'netmiko_device_type' field (e.g. 'cisco_ios_telnet'), "
f"or add a 'device_type:<type>' tag to this device in GNS3. "
f"To configure via Web UI: right-click the device -> Configure -> Tags -> add 'device_type:<type>'. "
f"Tested types: {tested_device_types}. "
f"Current tags: {tags}"
@ -134,7 +138,7 @@ def get_device_ports_from_topology(
continue
logger.debug(
"Device '%s': extracted device_type=%s from tags",
"Device '%s': device_type=%s",
device_name,
device_type,
)
@ -156,7 +160,7 @@ def get_device_ports_from_topology(
# This is the Nornir best practice - each host has its own
# connection configuration (device_type), while sharing common
# settings (hostname, timeout) via group inheritance.
hosts_data[device_name] = {
host_entry = {
"port": node_info["console_port"],
"platform": platform,
"groups": ["network_devices"], # For inheriting hostname, timeout, etc.
@ -167,6 +171,16 @@ def get_device_ports_from_topology(
},
}
# Per-node default credentials (seeded from the template appliance
# metadata) override the group's empty fallback. Only inject when
# set, so credential-less devices keep inheriting the group values.
if node_info.get("default_username"):
host_entry["username"] = node_info["default_username"]
if node_info.get("default_password"):
host_entry["password"] = node_info["default_password"]
hosts_data[device_name] = host_entry
logger.info("Returning %d device port mappings", len(hosts_data))
return hosts_data

View File

@ -192,6 +192,33 @@ async def create_ethernet_switch_nio(
return nio.asdict()
@router.put(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
)
async def update_ethernet_switch_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: EthernetSwitch = Depends(dep_node)
) -> schemas.UDPNIO:
"""
Update a NIO (Network Input/Output) on the node: re-apply the packet
filters and traffic-insight markers carried by the NIO onto the port's
uBridge relay. The adapter number on the switch is always 0.
"""
nio = node.get_nio(port_number)
nio.filters.clear()
if nio_data.filters:
nio.filters = nio_data.filters
nio.markers = nio_data.markers or {}
await node.update_nio(port_number, nio)
return nio.asdict()
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
async def delete_ethernet_switch_nio(
*,
@ -257,3 +284,75 @@ async def stream_pcap_file(
nio = node.get_nio(port_number)
stream = Builtin.instance().stream_pcap_file(nio, node.project.id)
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
@router.put("/{node_id}/markers/{marker_name}")
async def toggle_ethernet_switch_marker(
marker_name: str,
toggle_data: schemas.MarkerToggle,
node: EthernetSwitch = Depends(dep_node)
) -> dict:
"""
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
"""
if not any(n == marker_name for (n, lid) in node._marker_filter_bridges):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Marker '{marker_name}' is not installed on this node",
)
await node._ubridge_set_marker_filter_state(marker_name, toggle_data.enabled)
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
@router.post("/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT)
async def pause_ethernet_switch_markers(node: EthernetSwitch = Depends(dep_node)) -> None:
await node._ubridge_marker_pause()
@router.post("/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT)
async def resume_ethernet_switch_markers(node: EthernetSwitch = Depends(dep_node)) -> None:
await node._ubridge_marker_resume()
@router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_ethernet_switch_marker_capture(
*,
marker_name: str,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
link_id: str = "",
node: EthernetSwitch = Depends(dep_node)
) -> None:
"""
Delete a marker's capture pcap (called by the controller when the marker is
removed) so the file is cleaned up even with the switch stopped. Also drops
the marker from the port NIO's cached spec so a switch restart won't
reinstall it (and recreate an empty pcap). The adapter number is always 0.
"""
nio = node.get_nio(port_number)
await node.delete_marker_capture(marker_name, link_id, nio)
@router.put("/{node_id}/markers/{marker_name}/rebuild")
async def rebuild_ethernet_switch_marker(
marker_name: str,
rebuild_data: schemas.MarkerRebuild,
node: EthernetSwitch = Depends(dep_node)
) -> dict:
"""
Re-install a single marker filter with new BPF/tag/direction (delete + add,
no bridge reset) so sibling markers' pcaps stay open.
"""
await node.rebuild_marker_filter(
marker_name, rebuild_data.link_id, rebuild_data.bpf,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
)
return {"marker_name": marker_name}

View File

@ -61,6 +61,7 @@ from . import acl
from . import pools
from . import privileges
from . import api_keys
from . import netmiko
from .dependencies.authentication import get_current_active_user
@ -159,6 +160,12 @@ router.include_router(
tags=["Appliances"]
)
router.include_router(
netmiko.router,
prefix="/netmiko",
tags=["Netmiko"]
)
router.include_router(
pools.router,
prefix="/pools",

View File

@ -15,6 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
import hashlib
import logging
import bcrypt
@ -176,7 +177,14 @@ async def get_current_active_user_from_websocket(
return user
except HTTPException as e:
err_msg = f"Could not authenticate while connecting to controller WebSocket: {e.detail}"
# Fingerprint the received token so clients can compare it against the fingerprint
# returned when the token was issued (e.g. token_sha256_prefix from the
# node_console_info MCP tool) and detect copy corruption on their side.
token_sha256_prefix = hashlib.sha256(token.encode()).hexdigest()[:8]
err_msg = (
f"Could not authenticate while connecting to controller WebSocket: {e.detail} "
f"(received token sha256 prefix: {token_sha256_prefix})"
)
websocket_error = {"action": "log.error", "event": {"message": err_msg}}
await websocket.send_json(websocket_error)
log.error(err_msg)

View File

@ -0,0 +1,102 @@
#!/usr/bin/env python
#
# Copyright (C) 2020 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
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
API routes for Netmiko metadata.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from gns3server import schemas
from .dependencies.authentication import get_current_active_user
import logging
log = logging.getLogger(__name__)
router = APIRouter()
# Computed once per process: the list only changes if the installed
# Netmiko library changes, which requires a server restart anyway.
_device_types_cache: Optional[schemas.NetmikoDeviceTypeList] = None
def _load_netmiko_device_types() -> schemas.NetmikoDeviceTypeList:
"""
Build the list of device types supported by the installed Netmiko library.
Imports Netmiko and the GNS3-copilot custom drivers (which register
additional 'gns3_*' device types into Netmiko's CLASS_MAPPER on import),
then filters out the '<type>_ssh' aliases and the 'autodetect'
pseudo device type.
Raises:
ImportError: If Netmiko is not installed (ai-features extra).
"""
import importlib
import netmiko
# "from netmiko import ssh_dispatcher" is shadowed by a function of the same
# name in netmiko's __init__, so import the module through importlib
sd = importlib.import_module("netmiko.ssh_dispatcher")
# Importing the package auto-registers all custom drivers (in case nothing
# imported them yet); failures are logged by the package itself, do not
# fail the whole endpoint.
try:
from gns3server.agent.gns3_copilot.utils import custom_netmiko # noqa: F401
except Exception as e:
log.warning(f"Could not register GNS3-copilot custom Netmiko drivers: {e}")
# Custom drivers all use the 'gns3_' prefix by convention, which is more
# reliable than diffing CLASS_MAPPER around the import: the drivers may
# already be registered when the copilot package got imported at startup.
device_types = [
schemas.NetmikoDeviceType(name=name, telnet="_telnet" in name, custom=name.startswith("gns3_"))
for name in sorted(sd.CLASS_MAPPER.keys())
if not name.endswith("_ssh") and name != "autodetect"
]
return schemas.NetmikoDeviceTypeList(netmiko_version=netmiko.__version__, device_types=device_types)
@router.get(
"/device_types",
response_model=schemas.NetmikoDeviceTypeList,
dependencies=[Depends(get_current_active_user)]
)
def get_netmiko_device_types() -> schemas.NetmikoDeviceTypeList:
"""
Return the device types supported by the Netmiko library installed on this server.
Required privilege: None (authenticated users only)
"""
global _device_types_cache
if _device_types_cache is None:
try:
_device_types_cache = _load_netmiko_device_types()
except ImportError:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Netmiko is not available. Install AI dependencies with: pip install gns3-server[ai-features]"
)
return _device_types_cache

View File

@ -694,13 +694,19 @@ async def ws_console(
async def ws_receive(ws_console_compute):
"""
Receive WebSocket data from client and forward to compute console WebSocket.
Text frames carry terminal data; binary frames carry client control
messages (e.g. terminal size), forwarded as-is.
"""
try:
while True:
data = await websocket.receive_text()
if data:
await ws_console_compute.send_str(data)
msg = await websocket.receive()
if msg["type"] == "websocket.disconnect":
break
if "text" in msg and msg["text"]:
await ws_console_compute.send_str(msg["text"])
elif "bytes" in msg and msg["bytes"]:
await ws_console_compute.send_bytes(msg["bytes"])
except WebSocketDisconnect:
await ws_console_compute.close()
log.info(

View File

@ -213,14 +213,16 @@ async def _resolve_token(token: str) -> str | None:
Returns None if the token is invalid.
"""
# Try JWT first
try:
token_data = auth_service.get_token_data(token)
_jwt_username_var.set(token_data.username)
_jwt_token_version_var.set(token_data.token_version)
return token
except Exception:
pass
# API keys (gns3_...) are never valid JWTs — skip the JWT attempt for them
# so it doesn't log a spurious "JWT rejected" line on every API-key connection.
if not token.startswith("gns3_"):
try:
token_data = auth_service.get_token_data(token)
_jwt_username_var.set(token_data.username)
_jwt_token_version_var.set(token_data.token_version)
return token
except Exception:
pass
# Try API key — format: gns3_<api_key_id>_<random_secret> → O(1) lookup
if token.startswith("gns3_") and _app is not None:

View File

@ -25,6 +25,7 @@ via Gns3Connector (from custom_gns3fy).
from typing import Any
from concurrent.futures import ThreadPoolExecutor, as_completed
import hashlib
import logging
from gns3server.services import auth_service
@ -312,6 +313,12 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An
"ws_url": ws_url,
"command": f"websocat -t --no-close {ws_url}",
}
if ws_token:
# Fingerprint of the minted token: compare it against what actually reached the
# server (logged on WebSocket auth rejection) to detect copy corruption, and
# re-request the URL once token_ttl_seconds has elapsed.
result["token_sha256_prefix"] = hashlib.sha256(ws_token.encode()).hexdigest()[:8]
result["token_ttl_seconds"] = 600
if console_type in ("vnc",):
result["vnc_url"] = f"/v3/projects/{project_id}/nodes/{node_id}/console/vnc?token={gns3_ctx['jwt_token']}"
return result

View File

@ -19,6 +19,9 @@ import os
import stat
import shutil
import asyncio
import contextlib
import json
import struct
import tempfile
import psutil
import platform
@ -561,6 +564,51 @@ class BaseNode:
log.warning(f"Cannot connect to node {self.name} console server: {e}")
return
def _parse_terminal_size_message(data: bytes):
"""
Binary control frames sent by WebSocket console clients to propagate
their terminal geometry: {"cols": int, "rows": int}. Terminal data
travels as text frames (xterm.js AttachAddon), so binary frames are
an unambiguous side channel. Returns (cols, rows) or None.
"""
try:
message = json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, ValueError):
return None
if not isinstance(message, dict):
return None
cols, rows = message.get("cols"), message.get("rows")
if (
isinstance(cols, int) and not isinstance(cols, bool)
and isinstance(rows, int) and not isinstance(rows, bool)
and 2 <= cols <= 5000
and 2 <= rows <= 100000
):
return cols, rows
return None
async def resize_console(cols: int, rows: int) -> None:
"""
Propagate a client terminal resize to the node console stream:
SSH channels use a pty request update, telnet-based consoles
(including docker_exec) speak a NAWS subnegotiation to the console
telnet server, which resizes the underlying stream (e.g. the
docker exec pty).
"""
if self._console_type == "ssh":
with contextlib.suppress(AttributeError):
ssh_process.change_terminal_size(cols, rows)
else:
telnet_writer.write(
bytes([255, 251, 31]) # IAC WILL NAWS
+ bytes([255, 250, 31]) # IAC SB NAWS
+ struct.pack("!HH", cols, rows).replace(b"\xff", b"\xff\xff")
+ bytes([255, 240]) # IAC SE
)
await telnet_writer.drain()
async def ws_forward(telnet_writer):
try:
@ -571,6 +619,14 @@ class BaseNode:
if "text" in msg and msg["text"]:
data = msg["text"].encode()
elif "bytes" in msg and msg["bytes"]:
size = _parse_terminal_size_message(msg["bytes"])
if size is not None:
log.debug(
f"Console WebSocket client {websocket.client.host}:{websocket.client.port}"
f" resized terminal to {size[0]}x{size[1]}"
)
await resize_console(*size)
continue
data = msg["bytes"]
else:
continue

View File

@ -129,8 +129,10 @@ class DockerVM(BaseNode):
if ":" not in image:
image = f"{image}:latest"
self._image = image
self._start_command = start_command
self._environment = environment
# assign through the property setters so creation and updates apply
# the same value normalization (e.g. "" -> None)
self.start_command = start_command
self.environment = environment
self._cid = None
self._ethernet_adapters = []
self._temporary_directory = None
@ -138,10 +140,10 @@ class DockerVM(BaseNode):
self._vnc_process = None
self._vncconfig_process = None
self._console_resolution = console_resolution
self._console_http_path = console_http_path
self.console_http_path = console_http_path
self._console_http_port = console_http_port
self._console_websocket = None
self._extra_hosts = extra_hosts
self.extra_hosts = extra_hosts
self._extra_volumes = extra_volumes or []
self._extra_configs = extra_configs or []
self._memory = memory
@ -288,7 +290,9 @@ class DockerVM(BaseNode):
@console_http_path.setter
def console_http_path(self, path):
self._console_http_path = path
# the canonical "no path" value is "/" so that "", None and "/"
# all compare equal in the update diff
self._console_http_path = path or "/"
@property
def console_http_port(self):
@ -304,7 +308,8 @@ class DockerVM(BaseNode):
@environment.setter
def environment(self, command):
self._environment = command
# "" and None are the same "no environment variables" value
self._environment = command or None
@property
def extra_hosts(self):
@ -312,7 +317,8 @@ class DockerVM(BaseNode):
@extra_hosts.setter
def extra_hosts(self, extra_hosts):
self._extra_hosts = extra_hosts
# "" and None are the same "no extra hosts" value
self._extra_hosts = extra_hosts or None
@property
def extra_volumes(self):
@ -373,6 +379,51 @@ class DockerVM(BaseNode):
result = await self.manager.query("GET", f"images/{self._image}/json")
return result
def _persistent_volume_list(self, image_info, include_network_config=True):
"""
The in-container paths that get a persistent volume mount: GNS3's
/etc/network, every VOLUME declared by the image and the node's
extra_volumes. Overlapping paths are de-duplicated so that a path
covered by a more general volume is not mounted twice.
:param include_network_config: include GNS3's hardcoded /etc/network
volume (consumed by init.sh; subclasses that skip init.sh pass
False so the list matches the mounts they actually create).
"""
for volume in self._extra_volumes:
if not volume.strip() or volume[0] != "/" or volume.find("..") >= 0:
raise DockerError(
f"Persistent volume '{volume}' has invalid format. It must start with a '/' and not contain '..'."
)
volumes = []
if include_network_config:
volumes.append("/etc/network")
volumes.extend((image_info.get("Config", {}).get("Volumes") or {}).keys())
volumes.extend(self._extra_volumes)
deduped = []
# define lambdas for validation checks
nf = lambda x: re.sub(r"//+", "/", (x if x.endswith("/") else x + "/"))
generalises = lambda v1, v2: nf(v2).startswith(nf(v1))
for volume in volumes:
# remove any mount that is equal or more specific, then append this one
deduped = list(filter(lambda v: not generalises(volume, v), deduped))
# if there is nothing more general, append this mount
if not [v for v in deduped if generalises(v, volume)]:
deduped.append(volume)
return deduped
async def _prepare_volumes(self, image_info):
"""
Hook: prepare persistent volumes before the container (and its
mounts) are created. The default implementation does nothing
init.sh performs the first-copy seeding inside the container at
boot. Subclasses that skip init.sh override this to seed the host
directories from the image instead, so their mounts can be bound
directly at the real in-container paths from the very first process.
"""
def _mount_binds(self, image_info):
"""
:returns: Return the path that we need to map to local folders
@ -396,26 +447,7 @@ class DockerVM(BaseNode):
self._create_network_config()
except OSError as e:
raise DockerError(f"Could not create network config in the container: {e}")
volumes = ["/etc/network"]
volumes.extend((image_info.get("Config", {}).get("Volumes") or {}).keys())
for volume in self._extra_volumes:
if not volume.strip() or volume[0] != "/" or volume.find("..") >= 0:
raise DockerError(
f"Persistent volume '{volume}' has invalid format. It must start with a '/' and not contain '..'."
)
volumes.extend(self._extra_volumes)
self._volumes = []
# define lambdas for validation checks
nf = lambda x: re.sub(r"//+", "/", (x if x.endswith("/") else x + "/"))
generalises = lambda v1, v2: nf(v2).startswith(nf(v1))
for volume in volumes:
# remove any mount that is equal or more specific, then append this one
self._volumes = list(filter(lambda v: not generalises(volume, v), self._volumes))
# if there is nothing more general, append this mount
if not [v for v in self._volumes if generalises(v, volume)]:
self._volumes.append(volume)
self._volumes = self._persistent_volume_list(image_info)
for volume in self._volumes:
source = os.path.join(self.working_dir, os.path.relpath(volume, "/"))
@ -538,6 +570,10 @@ class DockerVM(BaseNode):
f"(max available is {available_cpus} CPUs)"
)
# Prepare persistent volume content before the container and its
# mounts are created (no-op for the init.sh path).
await self._prepare_volumes(image_infos)
params = {
"Hostname": self._name,
"Image": self._image,

View File

@ -48,13 +48,20 @@ class VendorDockerVM(DockerVM):
(host-side only GNS3_ entries are never forwarded into the container):
* ``GNS3_SKIP_INIT=1`` do not prepend /gns3/init.sh; the container runs
its own entrypoint (e.g. SR Linux's ``sr_linux``). Init.sh's volume
persistence (bind-mount /gns3volumes target) is replicated via
``docker exec`` after the container starts.
its own entrypoint (e.g. SR Linux's ``sr_linux``). Persistent volumes
are seeded host-side and bound directly at their real in-container
paths at create time (see ``_prepare_volumes`` / ``_mount_binds``), so
the NOS sees its saved configuration from the very first process
no post-start mount pass that could race the NOS reading its config.
* ``GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2`` rename injected interfaces
(adapter order) instead of default ``eth{N}``.
* ``GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli`` command run inside the
container by the ``docker_exec`` console (defaults to ``/bin/sh``).
* ``GNS3_CONSOLE_RESIZE=0`` ignore client-driven console resizes
(WS terminal-size frames / telnet NAWS). Set for CLIs that page on the
PTY window size (IOS-XR): the exec PTY must stay at the tall
no-NAWS default for every client, including concurrent netmiko
sessions on the shared exec.
* ``GNS3_STOP_TIMEOUT=60`` SIGTERM grace period in seconds when stopping
the container (default 60; Docker SIGKILLs once it expires).
"""
@ -77,6 +84,7 @@ class VendorDockerVM(DockerVM):
self._gns3_init = True
self._interface_names = []
self._console_cmd = None
self._console_resize = True
self._stop_timeout = 60
if self._environment:
for _line in self._environment.splitlines():
@ -89,6 +97,8 @@ class VendorDockerVM(DockerVM):
]
elif _line.startswith("GNS3_CONSOLE_CMD="):
self._console_cmd = _line.split("=", 1)[1].strip()
elif _line.startswith("GNS3_CONSOLE_RESIZE="):
self._console_resize = _line.split("=", 1)[1].strip().lower() not in ("0", "false", "no")
elif _line.startswith("GNS3_STOP_TIMEOUT="):
try:
timeout = int(_line.split("=", 1)[1].strip())
@ -119,6 +129,17 @@ class VendorDockerVM(DockerVM):
Removes the bind, drops the volume from self._volumes (so
GNS3_VOLUMES and the vendor passes stay consistent) and deletes the
host-side skeleton directory the base class just created.
Additionally, the persistent volumes are bound directly at their
real in-container paths instead of /gns3volumes<volume>. With
init.sh skipped there is no in-container mount pass, so a volume
bound at /gns3volumes would only be moved into place by a post-start
``docker exec`` racing the NOS reading its startup configuration
(an SR Linux node booted factory whenever the exec lost that race,
e.g. on the concurrent node starts of a project reload). Binding at
the real path is safe because the content is seeded host-side before
the container is created (see _prepare_volumes): the image's files
are never shadowed by an empty mount.
"""
binds = super()._mount_binds(image_info)
if self._gns3_init:
@ -128,7 +149,115 @@ class VendorDockerVM(DockerVM):
shutil.rmtree(os.path.join(self.working_dir, "etc", "network"), ignore_errors=True)
with contextlib.suppress(OSError):
os.rmdir(os.path.join(self.working_dir, "etc"))
return binds
# Re-target the volume binds from /gns3volumes<volume> to <volume>.
retargeted = []
for bind in binds:
target = bind.get("Target", "")
if target.startswith("/gns3volumes"):
volume = target[len("/gns3volumes"):]
if volume in self._volumes:
bind = {**bind, "Target": volume}
retargeted.append(bind)
return retargeted
async def _prepare_volumes(self, image_info):
"""
Override: for SKIP_INIT containers, seed every persistent volume's
host directory with the image's original content *before* the
container is created. This is the host-side replacement of init.sh's
first-copy: because the volume is then bound directly at its real
in-container path (see _mount_binds), the seed must exist first or
the NOS would boot with an empty config directory.
``.gns3_perms`` doubles as the seeded marker: a volume that has it
(every node that ever started, on any GNS3 version) is never
re-seeded a re-seed would overwrite the node's saved
configuration with the factory image content.
"""
if self._gns3_init:
return
volumes = self._persistent_volume_list(image_info, include_network_config=False)
to_seed = []
for volume in volumes:
host_dir = os.path.join(self.working_dir, os.path.relpath(volume, "/"))
os.makedirs(host_dir, exist_ok=True)
if not os.path.exists(os.path.join(host_dir, ".gns3_perms")):
to_seed.append((volume, host_dir))
if not to_seed:
return
seed_cid = await self._create_seed_container()
try:
for volume, host_dir in to_seed:
await self._seed_volume_from_container(seed_cid, volume, host_dir)
# Write the marker only after the copy attempt, mirroring
# init.sh: a volume without it is (re)seeded on the next
# create(), so a partial seed self-heals.
open(os.path.join(host_dir, ".gns3_perms"), "a").close()
finally:
await self._remove_seed_container(seed_cid)
async def _create_seed_container(self):
"""
A throwaway ``docker create`` container (nothing executes) used as
the copy source for seeding persistent volumes with the image's
original content.
"""
try:
process = await asyncio.subprocess.create_subprocess_exec(
"docker", "create", self._image,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
except OSError as e:
raise DockerError(f"Could not seed persistent volumes for '{self._name}': {e}")
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise DockerError(
f"Could not create a seeding container for image '{self._image}': "
f"{stderr.decode(errors='replace').strip()}"
)
return stdout.decode().strip()
async def _seed_volume_from_container(self, seed_cid, volume, host_dir):
"""
Copy one volume's original content from the seeding container to its
host directory with ``docker cp -a`` (preserves modes/ownership; no
dependency on tools inside the image).
"""
try:
process = await asyncio.subprocess.create_subprocess_exec(
"docker", "cp", "-a", f"{seed_cid}:{volume}/.", host_dir + "/",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
except OSError as e:
raise DockerError(f"Could not seed persistent volume '{volume}' for '{self._name}': {e}")
_, stderr = await process.communicate()
if process.returncode != 0:
# A path the image does not contain (e.g. XRd's /xr-storage-shadow)
# is not an error: the volume starts empty. Same tolerance as
# init.sh's first copy (cp -a ... 2>/dev/null).
log.info(
"Persistent volume '%s' on '%s' not seedable from image '%s' (%s); starting empty",
volume, self._name, self._image, stderr.decode(errors="replace").strip(),
)
return
log.info("Seeded persistent volume '%s' for '%s' from image '%s'", volume, self._name, self._image)
async def _remove_seed_container(self, seed_cid):
"""
Best-effort removal of the seeding container.
"""
try:
process = await asyncio.subprocess.create_subprocess_exec(
"docker", "rm", "-f", seed_cid,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
except OSError:
return
await process.communicate()
def _prepare_init_and_interface_env(self, params):
"""
@ -201,23 +330,22 @@ class VendorDockerVM(DockerVM):
async def start(self):
await super().start()
if self.status == "started" and not self._gns3_init:
await self._setup_skip_init_volumes()
# Fix host-side ownership of the seeded volume right away so the
# controller can read project files while the node runs. Reset the
# "fixed" flag afterwards: files written by the container during
# runtime still need the stop-time pass.
# Persistent volumes are seeded and bound directly at create time
# (see _prepare_volumes / _mount_binds), so there is no post-start
# bridge to run. Fix host-side ownership right away so the
# controller can read project files while the node runs, and reset
# the "fixed" flag: files written by the container during runtime
# still need the stop-time pass.
await self._fix_permissions()
self._permissions_fixed = False
async def _fix_permissions(self):
"""
Container-side override of DockerVM._fix_permissions for vendor NOS
containers. It targets the Docker bind-mount paths
(`/gns3volumes<volume>`) directly instead of the in-container paths:
the in-container paths only resolve to persistent storage while the
`mount --bind` bridge from _setup_skip_init_volumes is up, and after a
container restart the bridge is gone the base implementation would
then chown the overlay copy instead of the host files.
containers. The persistent volumes are Docker bind mounts created
with the container (see _mount_binds), so the in-container paths
resolve to the host-side files for the container's whole lifetime —
no /gns3volumes aliasing is needed.
The busybox script runs inside the container as root (a host-side
GNS3 process may be unprivileged and cannot chown root-owned files).
@ -240,7 +368,7 @@ class VendorDockerVM(DockerVM):
uid, gid = os.getuid(), os.getgid()
for volume in self._volumes:
target = f"/gns3volumes{volume}"
target = volume
log.debug("Docker container '%s' fix ownership on %s", self._name, target)
try:
# chown prefers the container's own coreutils over /gns3/bin/busybox:
@ -276,61 +404,6 @@ class VendorDockerVM(DockerVM):
else:
self._permissions_fixed = True
async def _setup_skip_init_volumes(self):
"""
Replicate the volume-persistence portion of init.sh (lines 3552) for
containers that skip init.sh (GNS3_SKIP_INIT=1).
On first start the container's original files are seeded into the
persistent host directory; on subsequent starts the persisted data
is bind-mounted over the in-container path so writes land on the host.
Permission-changes recorded by _fix_permissions at the previous
stop are restored (best-effort).
"""
for volume in self._volumes:
vol_target = f"/gns3volumes{volume}"
# fmt: off
script = (
f'mkdir -p "{volume}" && '
f'if [ ! -f "{vol_target}/.gns3_perms" ]; then '
f' /gns3/bin/busybox cp -a "{volume}/." "{vol_target}/" 2>/dev/null; '
f' /gns3/bin/busybox touch "{vol_target}/.gns3_perms"; '
f'fi && '
f'/gns3/bin/busybox mount --bind "{vol_target}" "{volume}" && '
f'while IFS=: read -r PERMS OWNER GROUP FILE; do '
f' [ -L "$FILE" ] || /gns3/bin/busybox chmod "$PERMS" "$FILE" 2>/dev/null; '
# chown: prefer the container's coreutils, fall back to busybox
# (see _fix_permissions -- static busybox chown aborts on
# mismatched-glibc NOS images like XRd).
f' ( command -v chown >/dev/null 2>&1 && chown -h "$OWNER:$GROUP" "$FILE" || /gns3/bin/busybox chown -h "$OWNER:$GROUP" "$FILE" ) 2>/dev/null; '
f'done < "{volume}/.gns3_perms"'
)
# fmt: on
try:
process = await asyncio.subprocess.create_subprocess_exec(
"docker",
"exec",
self._cid,
"sh",
"-c",
script,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
err = stderr.decode(errors="replace").strip()
log.warning(
"Volume setup for '%s' on container '%s' returned %d: %s",
volume, self._name, process.returncode, err,
)
else:
log.info("Volume '%s' bound to persistent storage for '%s'", volume, self._name)
except OSError as e:
log.warning(
"Could not setup volume '%s' for container '%s': %s", volume, self._name, e
)
async def _start_console_server(self):
"""
Override: add the ``docker_exec`` console type alongside the
@ -357,7 +430,13 @@ class VendorDockerVM(DockerVM):
Command from GNS3_CONSOLE_CMD.
"""
telnet = _LazyExecTelnetServer(self, self.manager, self._cid, self._console_cmd or "/bin/sh")
telnet = _LazyExecTelnetServer(
self,
self.manager,
self._cid,
self._console_cmd or "/bin/sh",
allow_resize=self._console_resize,
)
try:
self._telnet_servers.append(
await telnet.start(self._manager.port_manager.console_host, self.console)
@ -384,7 +463,7 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
CPR, producing a blank/degraded screen on reconnect.
"""
def __init__(self, vm, manager, cid, command):
def __init__(self, vm, manager, cid, command, allow_resize=True):
super().__init__(
reader=None,
writer=None,
@ -397,7 +476,9 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
self._manager = manager
self._cid = cid
self._command = command
self._allow_resize = allow_resize
self._exec_id = None
self._client_size = None # size received while no exec existed yet
self._broadcast_task = None
self._lock = asyncio.Lock()
self._log_name = f"docker_exec console '{vm.name}'"
@ -412,16 +493,42 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
return False
return True
async def _disconnect_client(self, network_writer):
await super()._disconnect_client(network_writer)
# When the last client leaves, restore the tall no-NAWS default: a
# browser client resizes the exec to its own geometry (WS terminal
# size control frames -> NAWS), and the next non-NAWS client (netmiko,
# bare telnet) connecting to the still-live exec would otherwise
# inherit it and hit PTY-window paging (the IOS-XR --More-- trap).
if self._exec_id and not await self._get_connections_snapshot():
with contextlib.suppress(Exception):
self._client_size = None
await self._resize_exec(511, 10000)
async def _resize_exec(self, columns, rows):
if not self._exec_id:
# No exec yet (first client still inside client_connected_hook):
# remember the size — the hook applies it right after creation
# instead of the tall default, so it doesn't get overwritten.
self._client_size = (columns, rows)
return
try:
await self._manager.query(
"POST",
f"exec/{self._exec_id}/resize",
params={"h": str(rows), "w": str(columns)},
)
except DockerError:
pass
async def _on_naws(self, columns, rows):
if self._exec_id:
try:
await self._manager.query(
"POST",
f"exec/{self._exec_id}/resize",
params={"h": str(rows), "w": str(columns)},
)
except DockerError:
pass
# Client-driven resize (WS terminal-size control frames, telnet NAWS).
# Ignored for paging CLIs (GNS3_CONSOLE_RESIZE=0): with the exec shared
# by all clients, one browser resize would break concurrent netmiko
# sessions that rely on the tall no-paging geometry.
if not self._allow_resize:
return
await self._resize_exec(columns, rows)
async def run(self, network_reader, network_writer):
"""Catch and log any exception that kills the client session."""
@ -504,7 +611,18 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
log.warning(f"{self._log_name}: failed to create exec: {exc}", exc_info=True)
raise
try:
await self._on_naws(80, 24) # initial size before NAWS
# Tall/wide default geometry before any NAWS arrives: a
# 24-row PTY makes CLIs that page on the PTY window size
# (e.g. the IOS-XR pager) park at --More-- for clients
# that never negotiate NAWS (netmiko, bare telnet).
# Width 511 matches netmiko's 'terminal width 511'.
# A size already pushed by this client (WS terminal-size
# control frames -> NAWS, racing the exec creation) wins
# over the default.
if self._client_size:
await self._resize_exec(*self._client_size)
else:
await self._resize_exec(511, 10000)
except Exception:
pass
else:

View File

@ -71,6 +71,17 @@ class Appliance:
@property
def type(self):
if self._data.get("registry_version", 0) >= 8:
# registry version 8: the node type comes from the settings template_type,
# the default settings take precedence over the other sets
settings_list = self._data.get("settings") or []
for settings in settings_list:
if settings.get("default"):
return settings.get("template_type", "qemu")
if settings_list:
return settings_list[0].get("template_type", "qemu")
return "qemu"
if "iou" in self._data:
return "iou"
elif "dynamips" in self._data:

View File

@ -174,7 +174,7 @@ class ApplianceManager:
version_images = version.get("images")
if version_images:
for appliance_key, appliance_file in version_images.items():
for image in appliance.images:
for image in appliance.images or []:
if appliance_file == image.get("filename"):
image_checksum = image.get("md5sum")
image_in_db = await images_repo.get_image_by_checksum(image_checksum)
@ -225,12 +225,15 @@ class ApplianceManager:
from . import Controller
# downloading missing custom symbol for this appliance
if appliance.symbol and not appliance.symbol.startswith(":/symbols/"):
destination_path = os.path.join(Controller.instance().symbols.symbols_path(), appliance.symbol)
template_data = ApplianceToTemplate().new_template(appliance.asdict(), version, "local") # FIXME: "local"
# download the custom symbol used by the template if it is missing;
# the symbol can be defined at the appliance, version or settings level
symbol = template_data.get("symbol")
if symbol and not symbol.startswith(":/symbols/"):
destination_path = os.path.join(Controller.instance().symbols.symbols_path(), symbol)
if not os.path.exists(destination_path):
await self._download_symbol(appliance.symbol, destination_path)
return ApplianceToTemplate().new_template(appliance.asdict(), version, "local") # FIXME: "local"
await self._download_symbol(symbol, destination_path)
return template_data
async def install_appliances_from_image(
self,
@ -290,11 +293,14 @@ class ApplianceManager:
if not appliance.versions:
raise ControllerBadRequestError(message=f"Appliance '{appliance_id}' do not have versions")
image_dir = default_images_directory(appliance.type)
for appliance_version_info in appliance.versions:
if appliance_version_info.get("name") == version:
try:
await self._find_appliance_version_images(appliance, appliance_version_info, images_repo, image_dir)
template_type = ApplianceToTemplate().get_template_type(appliance.asdict(), appliance_version_info)
if template_type != "docker":
# docker appliances have no image files to find or download
image_dir = default_images_directory(template_type)
await self._find_appliance_version_images(appliance, appliance_version_info, images_repo, image_dir)
except InvalidImageError as e:
raise ControllerError(message=f"Image error: {e}")
template_data = await self._appliance_to_template(appliance, appliance_version_info)
@ -362,7 +368,16 @@ class ApplianceManager:
symbol_theme = controller.symbols.theme
category = appliance["category"]
if category == "guest":
if "docker" in appliance:
if appliance.get("registry_version", 0) >= 8:
# registry version 8: the emulator type comes from the default
# settings set (or the only one present), not a top-level block
settings = appliance.get("settings") or []
selected = next((s for s in settings if s.get("default")), settings[0] if settings else None)
if selected and selected.get("template_type") == "docker":
return controller.symbols.get_default_symbol("docker_guest", symbol_theme)
if selected:
return controller.symbols.get_default_symbol("qemu_guest", symbol_theme)
elif "docker" in appliance:
return controller.symbols.get_default_symbol("docker_guest", symbol_theme)
elif "qemu" in appliance:
return controller.symbols.get_default_symbol("qemu_guest", symbol_theme)

View File

@ -18,9 +18,32 @@
import logging
from .controller_error import ControllerError
log = logging.getLogger(__name__)
# appliance fields that describe the appliance (vendor information, default
# credentials...) and are kept on the template as metadata instead of being
# dropped at installation time
_APPLIANCE_METADATA_FIELDS = (
"description",
"vendor_name",
"vendor_url",
"vendor_logo_url",
"documentation_url",
"product_name",
"product_url",
"status",
"availability",
"maintainer",
"maintainer_email",
"installation_instructions",
"default_username",
"default_password",
)
class ApplianceToTemplate:
"""
Appliance installation.
@ -31,6 +54,9 @@ class ApplianceToTemplate:
Creates a new template from an appliance.
"""
if appliance_config.get("registry_version", 0) >= 8:
return self._new_template_v8(appliance_config, version, server)
new_template = {
"compute_id": server,
"name": appliance_config["name"],
@ -53,6 +79,13 @@ class ApplianceToTemplate:
if "tags" in appliance_config:
new_template["tags"] = appliance_config.get("tags")
if appliance_config.get("netmiko_device_type"):
new_template["netmiko_device_type"] = appliance_config["netmiko_device_type"]
appliance_metadata = self._build_appliance_metadata(appliance_config, version)
if appliance_metadata:
new_template["appliance_metadata"] = appliance_metadata
if new_template.get("symbol") is None:
if appliance_config["category"] == "guest":
if "docker" in appliance_config:
@ -133,3 +166,172 @@ class ApplianceToTemplate:
new_config.update(appliance_config["iou"])
new_config["path"] = version.get("images").get("image")
def _new_template_v8(self, appliance_config, version, server):
"""
Creates a new template from an appliance using the registry version 8 format.
"""
settings = self._select_v8_settings(appliance_config, version)
properties = self._merge_v8_properties(settings, appliance_config)
new_template = {
"compute_id": server,
"template_type": settings["template_type"],
"name": appliance_config["name"],
}
if version:
new_template["version"] = version.get("name")
# category/usage/symbol can be defined in template_properties (already merged above),
# otherwise at the version level, otherwise at the appliance level
for prop in ("category", "usage", "symbol"):
if prop not in properties:
if version and version.get(prop) is not None:
properties[prop] = version[prop]
elif appliance_config.get(prop) is not None:
properties[prop] = appliance_config[prop]
category_before_remap = properties.get("category")
if category_before_remap == "multilayer_switch":
properties["category"] = "switch"
if settings["template_type"] == "qemu":
# kvm is not a valid template property: convert it to the
# equivalent qemu options like for registry versions 1-6
kvm = properties.pop("kvm", None) or "allow"
options = properties.get("options") or ""
if kvm == "disable" and "-machine accel=tcg" not in options:
options += " -machine accel=tcg"
properties["options"] = options.strip()
# template_properties must not override the structural fields
for reserved in ("template_type", "compute_id", "version"):
properties.pop(reserved, None)
new_template.update(properties)
if "tags" in appliance_config:
new_template["tags"] = appliance_config.get("tags")
if appliance_config.get("netmiko_device_type"):
new_template["netmiko_device_type"] = appliance_config["netmiko_device_type"]
appliance_metadata = self._build_appliance_metadata(appliance_config, version)
if appliance_metadata:
new_template["appliance_metadata"] = appliance_metadata
if not new_template.get("symbol"):
# apply a default symbol based on the effective category and template type
if category_before_remap == "guest":
if settings["template_type"] == "docker":
new_template["symbol"] = ":/symbols/docker_guest.svg"
else:
new_template["symbol"] = ":/symbols/qemu_guest.svg"
else:
symbols = {
"router": ":/symbols/router.svg",
"switch": ":/symbols/ethernet_switch.svg",
"multilayer_switch": ":/symbols/multilayer_switch.svg",
"firewall": ":/symbols/firewall.svg",
}
new_template["symbol"] = symbols.get(category_before_remap)
if version and version.get("images"):
if settings["template_type"] == "iou":
# IOU templates take the image path, not an image name
new_template["path"] = version["images"].get("image")
else:
new_template.update(version["images"])
if version and settings["template_type"] == "dynamips" and version.get("idlepc"):
# settings level idlepc takes precedence over the version level
new_template.setdefault("idlepc", version["idlepc"])
return new_template
def _build_appliance_metadata(self, appliance_config, version):
"""
Builds the appliance metadata kept on the template: the fields that
describe the appliance, with version level values (e.g. credentials
specific to the installed version) overriding the appliance level ones.
"""
version = version or {}
metadata = {}
for field in _APPLIANCE_METADATA_FIELDS:
value = version.get(field)
if value is None:
value = appliance_config.get(field)
if value is not None:
metadata[field] = value
appliance_id = appliance_config.get("appliance_id")
if appliance_id:
metadata["appliance_id"] = str(appliance_id)
return metadata or None
def get_template_type(self, appliance_config, version):
"""
Returns the template type of the settings set used to install the given
version: for registry versions 1-6 it comes from the emulator block, for
version 8 from the settings set selected for the version.
"""
if appliance_config.get("registry_version", 0) >= 8:
return self._select_v8_settings(appliance_config, version)["template_type"]
if "iou" in appliance_config:
return "iou"
if "dynamips" in appliance_config:
return "dynamips"
if "docker" in appliance_config:
return "docker"
return "qemu"
def _select_v8_settings(self, appliance_config, version):
"""
Selects the settings set to use: the one referenced by the version,
otherwise the default set, otherwise the only set present.
"""
settings_list = appliance_config.get("settings") or []
if not settings_list:
raise ControllerError(f"Appliance '{appliance_config['name']}' has no settings")
if version and version.get("settings"):
settings_name = version["settings"]
for settings in settings_list:
if settings.get("name") == settings_name:
return settings
raise ControllerError(
f"Could not find settings '{settings_name}' referenced by "
f"version '{version.get('name')}' in appliance '{appliance_config['name']}'"
)
for settings in settings_list:
if settings.get("default"):
return settings
if len(settings_list) == 1:
return settings_list[0]
raise ControllerError(
f"Appliance '{appliance_config['name']}' has multiple settings "
f"but none is marked as default"
)
def _merge_v8_properties(self, settings, appliance_config):
"""
Merges the template properties of the selected settings with the default
settings properties, unless inheritance is disabled or the default set
is selected. Only a default set of the same emulator type is inherited
from, so properties of a different type never pollute the template.
"""
properties = {}
if not settings.get("default") and settings.get("inherit_default_properties", True):
for other_settings in appliance_config.get("settings") or []:
if other_settings.get("default") and other_settings.get("template_type") == settings["template_type"]:
properties.update(other_settings.get("template_properties") or {})
break
properties.update(settings.get("template_properties") or {})
return properties

View File

@ -24,7 +24,6 @@ import sys
import io
from fastapi import HTTPException
from aiohttp import web
if sys.version_info >= (3, 11):
from asyncio import timeout as asynctimeout
@ -373,6 +372,27 @@ class Compute:
except ControllerError:
pass
async def _report_connection_failure(self, error):
"""
Update the connection state after a failure, notify clients and
schedule a reconnection attempt with exponential backoff.
"""
self._connected = False
self._last_error = str(error)
self._controller.notification.controller_emit("compute.updated", self.asdict())
# Try to reconnect if server unavailable only if not during tests (otherwise we create a ressource usage bomb)
if hasattr(sys, "_called_from_test") and sys._called_from_test:
return
self._connection_failure += 1
# After 10 failures we close the project using the compute to avoid sync issues
if self._connection_failure == 10:
log.error(f"Could not connect to compute '{self._id}' after multiple attempts: {error}")
await self._controller.close_compute_projects(self)
# Exponential backoff: 5s, 10s, 20s, 40s, 80s, then cap at 300s
delay = min(5 * (2 ** (self._connection_failure - 1)), 300)
asyncio.get_event_loop().call_later(delay, lambda: asyncio.ensure_future(self._try_reconnect()))
@locking
async def connect(self, report_failed_connection=False):
"""
@ -385,32 +405,20 @@ class Compute:
response = await self._run_http_query("GET", "/capabilities")
except ComputeError as e:
# Update connection status and notify UI
self._connected = False
self._last_error = str(e)
self._controller.notification.controller_emit("compute.updated", self.asdict())
await self._report_connection_failure(e)
if report_failed_connection:
raise
log.warning(f"Cannot connect to compute '{self._id}': {e}")
# Try to reconnect if server unavailable only if not during tests (otherwise we create a ressource usage bomb)
if not hasattr(sys, "_called_from_test") or not sys._called_from_test:
self._connection_failure += 1
# After 10 failures we close the project using the compute to avoid sync issues
if self._connection_failure == 10:
log.error(f"Could not connect to compute '{self._id}' after multiple attempts: {e}")
await self._controller.close_compute_projects(self)
# Exponential backoff: 5s, 10s, 20s, 40s, 80s, then cap at 300s
delay = min(5 * (2 ** (self._connection_failure - 1)), 300)
asyncio.get_event_loop().call_later(delay, lambda: asyncio.ensure_future(self._try_reconnect()))
return
except web.HTTPNotFound:
raise ControllerNotFoundError(f"The server {self._id} is not a GNS3 server or it's a 1.X server")
except web.HTTPUnauthorized:
raise ControllerUnauthorizedError(f"Invalid auth for server {self._id}")
except web.HTTPServiceUnavailable:
raise ControllerNotFoundError(f"The server {self._id} is unavailable")
except ValueError:
raise ComputeError(f"Invalid server url for server {self._id}")
except (ControllerError, HTTPException) as e:
# _run_http_query translates HTTP status errors into ControllerError
# subclasses (or a raw HTTPException for unexpected status codes).
# They used to escape this method and silently kill the fire-and-forget
# connect() task started at controller startup: no notification, no retry.
# Schedule the retry, then re-raise so explicit callers still get the error.
await self._report_connection_failure(e)
log.warning(f"Cannot connect to compute '{self._id}': {e}")
raise
if "version" not in response.json:
msg = f"The server {self._id} is not a GNS3 server"
@ -488,22 +496,27 @@ class Compute:
elif response.type == aiohttp.WSMsgType.CLOSED:
pass
break
except aiohttp.ClientError as e:
log.error(f"Client response error received on compute '{self._id}' WebSocket '{ws_url}': {e}")
except asyncio.CancelledError:
raise
except Exception as e:
# A malformed frame or an error raised while dispatching a compute event
# used to escape this task (only aiohttp.ClientError was caught) and
# permanently killed the notification stream: no more compute.updated
# events and no reconnection until the server was restarted. Log the
# error with its traceback and reconnect below.
log.error(f"Error on compute '{self._id}' notification stream '{ws_url}': {e!r}", exc_info=True)
finally:
self._connected = False
self._cpu_usage_percent = None
self._memory_usage_percent = None
self._disk_usage_percent = None
log.info(f"Connection closed to compute '{self._id}' WebSocket '{ws_url}'")
# Try to reconnect after 1 second if server unavailable only if not during tests (otherwise we create a resources usage bomb)
from gns3server.api.server import app
if not app.state.exiting and not hasattr(sys, "_called_from_test"):
log.info(f"Reconnecting to compute '{self._id}' WebSocket '{ws_url}'")
asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(self.connect()))
self._cpu_usage_percent = None
self._memory_usage_percent = None
self._disk_usage_percent = None
self._controller.notification.controller_emit("compute.updated", self.asdict())
self._controller.notification.controller_emit("compute.updated", self.asdict())
# Try to reconnect after 1 second if server unavailable only if not during tests (otherwise we create a resources usage bomb)
from gns3server.api.server import app
if not app.state.exiting and not hasattr(sys, "_called_from_test"):
log.info(f"Reconnecting to compute '{self._id}' WebSocket '{ws_url}'")
asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(self.connect()))
def _getUrl(self, path):
host = self._host

View File

@ -624,6 +624,8 @@ class Link:
"nat",
"virtualbox",
"docker",
# the brctl Ethernet switch applies filters on its per-port uBridge relays
"ethernet_switch",
):
return node["node"]
return None

View File

@ -57,6 +57,9 @@ class Node:
"ports",
"category",
"console_auto_start",
"netmiko_device_type",
"default_username",
"default_password",
]
def __init__(self, project, compute, name, node_id=None, node_type=None, template_id=None, **kwargs):
@ -112,6 +115,9 @@ class Node:
self._port_segment_size = 0
self._first_port_name = None
self._console_auto_start = False
self._netmiko_device_type = None
self._default_username = None
self._default_password = None
# This properties will be recomputed
ignore_properties = ("width", "height", "hover_symbol")
@ -212,6 +218,30 @@ class Node:
def console_auto_start(self, val):
self._console_auto_start = val
@property
def netmiko_device_type(self):
return self._netmiko_device_type
@netmiko_device_type.setter
def netmiko_device_type(self, val):
self._netmiko_device_type = val
@property
def default_username(self):
return self._default_username
@default_username.setter
def default_username(self, val):
self._default_username = val
@property
def default_password(self):
return self._default_password
@default_password.setter
def default_password(self, val):
self._default_password = val
@property
def properties(self):
return self._properties
@ -833,6 +863,9 @@ class Node:
"console": self._console,
"console_type": self._console_type,
"console_auto_start": self._console_auto_start,
"netmiko_device_type": self._netmiko_device_type,
"default_username": self._default_username,
"default_password": self._default_password,
"aux": self._aux,
"aux_type": self._aux_type,
"properties": self._properties,

View File

@ -584,6 +584,12 @@ class Project:
default_name_format = template.pop("default_name_format", "{name}-{0}")
if name is None:
name = default_name_format.replace("{name}", template_name)
# the appliance metadata stays template level: only the default
# credentials are seeded on the node (where they can be overridden)
appliance_metadata = template.pop("appliance_metadata", None) or {}
for field in ("default_username", "default_password"):
if appliance_metadata.get(field):
template[field] = appliance_metadata[field]
node_id = str(uuid.uuid4())
node = await self.add_node(compute, name, node_id, node_type=node_type, **template)
return node

View File

@ -24,12 +24,13 @@ from .link import Link, _UNSET
from .node_types import BUILTIN_NODE_TYPES
from gns3server.utils.packet_filter_validation import validate_bpf_syntax, FilterValidationError
# Node types without a uBridge bridge — a marker filter has nothing to attach to.
# Node types that can host a marker (have a uBridge bridge to attach the
# `mark` filter to). Mirrors _get_filter_node in link.py, minus "nat"
# (which has no uBridge).
# (which has no uBridge) and "ethernet_hub" (still Dynamips-hosted, no
# uBridge of its own). "ethernet_switch" hosts markers on the per-port
# uBridge relays of its brctl kernel-bridge backend.
_MARKER_CAPABLE_TYPES = frozenset({
"vpcs", "qemu", "docker", "iou", "dynamips", "cloud",
"vpcs", "qemu", "docker", "iou", "dynamips", "cloud", "ethernet_switch",
})
@ -234,7 +235,10 @@ class UDPLink(Link):
self._link_data[0]["filters"] = node1_filters
self._link_data[0]["markers"] = node1_markers
self._link_data[0]["suspend"] = self._suspended
if node1.node_type not in ("ethernet_switch", "ethernet_hub"):
# The Ethernet hub is still Dynamips-hosted (no uBridge of its own and
# no PUT NIO route) — keep skipping its side. Every other node type,
# including the brctl Ethernet switch, re-applies via the NIO update.
if node1.node_type != "ethernet_hub":
await node1.put(
f"/adapters/{adapter_number1}/ports/{port_number1}/nio", data=self._link_data[0], timeout=120
)
@ -244,7 +248,7 @@ class UDPLink(Link):
self._link_data[1]["filters"] = node2_filters
self._link_data[1]["markers"] = node2_markers
self._link_data[1]["suspend"] = self._suspended
if node2.node_type not in ("ethernet_switch", "ethernet_hub"):
if node2.node_type != "ethernet_hub":
await node2.put(
f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=self._link_data[1], timeout=221
)

View File

@ -35,6 +35,8 @@ class Template(BaseTable):
symbol = Column(String)
builtin = Column(Boolean, default=False)
usage = Column(String)
netmiko_device_type = Column(String)
appliance_metadata = Column(JSON)
template_type = Column(String)
tags = Column(JSON)
compute_id = Column(String)

View File

@ -0,0 +1,26 @@
"""add netmiko_device_type to templates table
Revision ID: b3c7e2a91d4f
Revises: 8f2a1c4e9d3b
Create Date: 2026-08-16 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b3c7e2a91d4f'
down_revision = '8f2a1c4e9d3b'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('templates', sa.Column('netmiko_device_type', sa.String()))
def downgrade() -> None:
op.drop_column('templates', 'netmiko_device_type')

View File

@ -0,0 +1,26 @@
"""add appliance_metadata to templates table
Revision ID: c7e4a9f1d2b6
Revises: b3c7e2a91d4f
Create Date: 2026-08-16 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c7e4a9f1d2b6'
down_revision = 'b3c7e2a91d4f'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('templates', sa.Column('appliance_metadata', sa.JSON()))
def downgrade() -> None:
op.drop_column('templates', 'appliance_metadata')

View File

@ -61,6 +61,7 @@ from .controller.tokens import Token, ApiKeyCreate, RefreshTokenRequest
from .controller.snapshots import SnapshotCreate, Snapshot
from .controller.iou_license import IOULicense
from .controller.capabilities import Capabilities
from .controller.netmiko import NetmikoDeviceType, NetmikoDeviceTypeList
# Controller template schemas
from .controller.templates.vpcs_templates import VPCSTemplate, VPCSTemplateUpdate

View File

@ -14,7 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List
from uuid import UUID
@ -26,6 +26,21 @@ class DockerBase(BaseModel):
Common Docker node properties.
"""
@field_validator("start_command", "environment", "extra_hosts", mode="before")
@classmethod
def _empty_string_to_none(cls, value):
# Web clients serialize empty form fields as "" while unset values are
# stored as None on the node: normalize before the update diff runs,
# otherwise every full PUT would see a phantom change and recreate
# the container for nothing.
return value or None
@field_validator("console_http_path", mode="before")
@classmethod
def _empty_string_to_root_path(cls, value):
# the canonical "no path" value is "/" (the creation default)
return value or "/"
name: str
image: str = Field(..., description="Docker image name")
node_id: Optional[UUID] = None

View File

@ -19,7 +19,7 @@
from enum import Enum
from typing import Annotated, List, Literal, Optional, Union
from uuid import UUID
from pydantic import AnyUrl, BaseModel, Discriminator, EmailStr, Field, Tag
from pydantic import AnyUrl, BaseModel, Discriminator, EmailStr, Field, Tag, model_validator
from ..common import ExtraConfig
@ -481,6 +481,7 @@ class DockerPropertiesV8(BaseModel):
extra_volumes: Optional[List[str]] = Field(
None, title='Additional directories to make persistent'
)
custom_adapters: Optional[List[CustomAdapterItem]] = Field(None, title='Custom adapters')
extra_configs: Optional[List[ExtraConfig]] = Field(
None, title='Configuration files injected into the container (bind-mounted read-only)'
)
@ -566,14 +567,23 @@ class QemuPropertiesV8(BaseModel):
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')
kvm: Optional[Kvm] = Field(None, title='KVM requirements')
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')
cpu_throttling: Optional[Annotated[int, Field(ge=0, le=800)]] = 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')
_V8_PROPERTIES_MODELS = {
TemplateType.qemu: QemuPropertiesV8,
TemplateType.dynamips: DynamipsPropertiesV8,
TemplateType.iou: IouPropertiesV8,
TemplateType.docker: DockerPropertiesV8,
}
class TemplateSetting(BaseModel):
"""Emulator settings configuration (v8)"""
@ -586,12 +596,34 @@ class TemplateSetting(BaseModel):
title='Properties for the template'
)
@model_validator(mode='before')
@classmethod
def _validate_template_properties(cls, data):
"""
Validate template_properties against the model matching template_type.
The template_type discriminator lives at the settings level (not inside
template_properties), so the union cannot be discriminated by pydantic
alone and would misroute properties between the per-type models.
"""
if isinstance(data, dict):
# work on a copy: replacing template_properties with the validated
# model must not mutate the caller's data
data = data.copy()
template_type = data.get("template_type")
template_properties = data.get("template_properties")
model = _V8_PROPERTIES_MODELS.get(template_type)
if model is not None and isinstance(template_properties, dict):
data["template_properties"] = model.model_validate(template_properties)
return data
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')
idlepc: Optional[str] = Field(None, pattern=r'^0x[0-9a-f]{8}')
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')
@ -631,6 +663,9 @@ class ApplianceV1_6(BaseModel):
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')
netmiko_device_type: Optional[str] = Field(
None, title='Device type for Netmiko-based automation tools', pattern=r'^[a-z0-9_]+$|^$'
)
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(
@ -684,6 +719,9 @@ class ApplianceV8(BaseModel):
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')
netmiko_device_type: Optional[str] = Field(
None, title='Device type for Netmiko-based automation tools', pattern=r'^[a-z0-9_]+$|^$'
)
tags: Optional[List[str]] = Field(None, title='User-defined metadata tags for the appliance')
settings: List[TemplateSetting] = Field(..., title='Settings for running the appliance')
images: Optional[List[ApplianceImage]] = Field(None, title='Images for this appliance')

View File

@ -0,0 +1,38 @@
#
# Copyright (C) 2020 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
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from pydantic import BaseModel, Field
from typing import List
class NetmikoDeviceType(BaseModel):
"""
A Netmiko device type supported by the installed Netmiko library.
"""
name: str = Field(..., description="Device type name to store in the netmiko_device_type field")
telnet: bool = Field(False, description="Whether the device type connects over Telnet")
custom: bool = Field(False, description="Whether the device type is a GNS3-copilot custom driver (gns3_ prefix)")
class NetmikoDeviceTypeList(BaseModel):
"""
List of Netmiko device types supported by the installed Netmiko library.
"""
netmiko_version: str = Field(..., description="Version of the installed Netmiko library")
device_types: List[NetmikoDeviceType] = Field(..., description="Supported device types, sorted by name")

View File

@ -116,6 +116,19 @@ class NodeBase(BaseModel):
console_auto_start: Optional[bool] = Field(
False, description="Automatically start the console when the node has started"
)
netmiko_device_type: Optional[str] = Field(
None,
description="Device type for Netmiko-based automation tools, overrides the template value",
pattern=r"^[a-z0-9_]+$|^$",
)
default_username: Optional[str] = Field(
None,
description="Default username to log into the node, seeded from the template appliance metadata",
)
default_password: Optional[str] = Field(
None,
description="Default password to log into the node, seeded from the template appliance metadata",
)
aux: Optional[int] = Field(None, gt=0, le=65535, description="Auxiliary console TCP port")
aux_type: Optional[ConsoleType] = None
properties: Optional[dict] = Field(default_factory=dict, description="Properties specific to an emulator")

View File

@ -34,6 +34,34 @@ class Category(str, Enum):
firewall = "firewall"
class ApplianceMetadata(BaseModel):
"""
Metadata kept on a template installed from an appliance: vendor
information, default credentials and other fields that describe
the appliance but are not node properties.
"""
model_config = ConfigDict(extra="allow")
appliance_id: Optional[str] = Field(
None, description="ID of the appliance the template was installed from"
)
description: Optional[str] = None
vendor_name: Optional[str] = None
vendor_url: Optional[str] = None
vendor_logo_url: Optional[str] = None
documentation_url: Optional[str] = None
product_name: Optional[str] = None
product_url: Optional[str] = None
status: Optional[str] = None
availability: Optional[str] = None
maintainer: Optional[str] = None
maintainer_email: Optional[str] = None
installation_instructions: Optional[str] = None
default_username: Optional[str] = None
default_password: Optional[str] = None
class TemplateBase(BaseModel):
"""
Common template properties.
@ -48,10 +76,19 @@ class TemplateBase(BaseModel):
template_type: Optional[NodeType] = None
compute_id: Optional[str] = None
usage: Optional[str] = ""
netmiko_device_type: Optional[str] = Field(
None,
description="Device type for Netmiko-based automation tools (e.g. 'cisco_xr' or 'nokia_srl')",
pattern=r"^[a-z0-9_]+$|^$",
)
tags: Optional[List[str]] = Field(
default_factory=list,
description="User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')"
)
appliance_metadata: Optional[ApplianceMetadata] = Field(
None,
description="Metadata inherited from the appliance the template was installed from"
)
class TemplateCreate(TemplateBase):

View File

@ -16,7 +16,9 @@
from joserfc import jwt
from joserfc.jwk import OctKey
from joserfc.errors import JoseError
from joserfc.errors import JoseError, BadSignatureError
import base64
import json
import time
from datetime import datetime, timedelta, timezone
import bcrypt
@ -34,6 +36,17 @@ log = logging.getLogger(__name__)
DEFAULT_JWT_SECRET_KEY = "efd08eccec3bd0a1be2e086670e5efa90969c68d07e072d7354a76cea5e33d4e"
def _extract_alg(token: str) -> str:
"""Best-effort extraction of the unverified JWT header "alg" value — for logging only."""
try:
header_segment = token.split(".", 1)[0]
header = json.loads(base64.urlsafe_b64decode(header_segment + "=" * (-len(header_segment) % 4)))
return str(header.get("alg", "<missing>"))
except Exception:
return "<undecodable>"
class AuthService:
def hash_password(self, password: str) -> str:
@ -75,32 +88,38 @@ class AuthService:
def get_token_data(self, token: str, secret_key: str = None) -> TokenData:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
def auth_error(detail: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail,
headers={"WWW-Authenticate": "Bearer"},
)
if secret_key is None:
secret_key = Config.instance().settings.Controller.jwt_secret_key
if secret_key is None:
secret_key = DEFAULT_JWT_SECRET_KEY
log.error("A JWT secret key must be configured to secure the server, using an unsecured default key!")
algorithm = Config.instance().settings.Controller.jwt_algorithm
key = OctKey.import_key(secret_key)
try:
if secret_key is None:
secret_key = Config.instance().settings.Controller.jwt_secret_key
if secret_key is None:
secret_key = DEFAULT_JWT_SECRET_KEY
log.error("A JWT secret key must be configured to secure the server, using an unsecured default key!")
algorithm = Config.instance().settings.Controller.jwt_algorithm
key = OctKey.import_key(secret_key)
payload = jwt.decode(token, key, algorithms=[algorithm])
username: str = payload.claims.get("sub")
if username is None:
raise credentials_exception
raise auth_error("Invalid token: missing subject claim")
# Validate the exp claim — joserfc does not validate time-based claims by default
token_exp: int = payload.claims.get("exp", 0)
if token_exp and time.time() > token_exp:
raise credentials_exception
raise auth_error("Token has expired")
token_version: int = payload.claims.get("ver", 0)
token_use: str = payload.claims.get("type", "access")
token_data = TokenData(username=username, token_version=token_version, token_use=token_use)
except (JoseError, ValidationError, ValueError):
raise credentials_exception
except BadSignatureError as e:
log.warning("JWT rejected: bad signature (header alg: '%s', error: %s)", _extract_alg(token), e)
raise auth_error("Invalid token signature")
except (JoseError, ValidationError, ValueError) as e:
log.warning("JWT rejected: %s: %s (header alg: '%s')", type(e).__name__, e, _extract_alg(token))
raise auth_error(f"Invalid token ({type(e).__name__})")
return token_data
def get_username_from_token(self, token: str, secret_key: str = None) -> Optional[str]:

View File

@ -17,6 +17,7 @@
import asyncio
import json
import time
import psutil
from gns3server.utils.cpu_percent import CpuPercent
@ -35,22 +36,39 @@ class NotificationQueue(asyncio.Queue):
def __init__(self):
super().__init__()
self._first = True
self._last_ping = None
async def get(self, timeout):
"""
When timeout is expire we send a ping notification with server information
Return a notification, or a ping notification with server information
at least every `timeout` seconds. The ping used to be generated only
when the queue was idle for the full timeout, which starved it under
sustained event load (e.g. high marker.match rates): clients stopped
receiving compute statistics until the event flow paused.
"""
# At first get we return a ping so the client immediately receives data
if self._first:
self._first = False
return ("ping", self._getPing(), {})
return self._ping()
try:
(action, msg, kwargs) = await asyncio.wait_for(super().get(), timeout)
except asyncio.TimeoutError:
return ("ping", self._getPing(), {})
return (action, msg, kwargs)
while True:
now = time.monotonic()
if self._last_ping is None or now - self._last_ping >= timeout:
return self._ping()
try:
(action, msg, kwargs) = await asyncio.wait_for(super().get(), timeout - (now - self._last_ping))
return (action, msg, kwargs)
except asyncio.TimeoutError:
continue # the ping deadline has been reached
def _ping(self):
"""
Build a ping notification and stamp the ping deadline.
"""
self._last_ping = time.monotonic()
return ("ping", self._getPing(), {})
def _getPing(self):
"""

View File

@ -69,3 +69,187 @@ def test_node_accepts_docker_exec_console():
status="started",
)
assert node.console_type == "docker_exec"
def test_node_accepts_netmiko_device_type():
"""
The vendored Node model must keep the netmiko_device_type field so the
device-port tools can prefer it over the device_type:<type> tag.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node
node = Node(
name="SR1",
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
node_type="docker",
console_type="docker_exec",
status="started",
netmiko_device_type="nokia_srl",
)
assert node.netmiko_device_type == "nokia_srl"
def test_device_ports_prefer_netmiko_field_over_tag(monkeypatch):
"""
netmiko_device_type on the node wins over the device_type:<type> tag;
the tag stays as fallback when the field is missing.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
from gns3server.agent.gns3_copilot import gns3_client
class _FakeTopology:
def _run(self, project_id=None, jwt_token=None, url=None):
return {
"nodes": {
"SR1": {
"console_port": 5000,
"tags": ["device_type:cisco_ios_telnet"],
"netmiko_device_type": "nokia_srl",
},
"R1": {
"console_port": 5001,
"tags": ["device_type:cisco_ios_telnet", "platform:cisco_ios"],
"netmiko_device_type": None,
},
}
}
# the function does a lazy from-import inside the body
monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology)
hosts = get_gns3_device_port.get_device_ports_from_topology(["SR1", "R1"])
# field wins over tag
assert hosts["SR1"]["connection_options"]["netmiko"]["extras"]["device_type"] == "nokia_srl"
# tag fallback when the field is absent
assert hosts["R1"]["connection_options"]["netmiko"]["extras"]["device_type"] == "cisco_ios_telnet"
assert hosts["R1"]["platform"] == "cisco_ios"
def test_device_ports_error_without_any_device_type(monkeypatch):
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
from gns3server.agent.gns3_copilot import gns3_client
class _FakeTopology:
def _run(self, project_id=None, jwt_token=None, url=None):
return {
"nodes": {
"R2": {
"console_port": 5002,
"tags": ["platform:cisco_ios"],
},
}
}
# the function does a lazy from-import inside the body
monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology)
hosts = get_gns3_device_port.get_device_ports_from_topology(["R2"])
assert "error" in hosts["R2"]
assert "netmiko_device_type" in hosts["R2"]["error"]
def test_node_accepts_default_credentials():
"""
The vendored Node model must keep the default credentials so the
device-port tools can log into devices that require authentication.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node
node = Node(
name="R1",
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
node_type="docker",
console_type="telnet",
status="started",
default_username="admin",
default_password="admin123",
)
assert node.default_username == "admin"
assert node.default_password == "admin123"
def test_nodes_inventory_emits_default_credentials():
"""
The inventory dict consumed by get_device_ports_from_topology must
carry the per-node default credentials.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from types import SimpleNamespace
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node, Project
project = Project(
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
connector=SimpleNamespace(base_url="http://127.0.0.1:3080"),
)
project.nodes = [
Node(
name="R1",
project_id=project.project_id,
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
node_type="dynamips",
console=5000,
default_username="admin",
default_password="admin123",
),
]
inventory = project.nodes_inventory()
assert inventory["R1"]["default_username"] == "admin"
assert inventory["R1"]["default_password"] == "admin123"
def test_device_ports_inject_default_credentials(monkeypatch):
"""
Per-node default credentials become host-level nornir values (which
override the group's empty fallback); missing or cleared ("") values
keep inheriting from the group.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
from gns3server.agent.gns3_copilot import gns3_client
class _FakeTopology:
def _run(self, project_id=None, jwt_token=None, url=None):
return {
"nodes": {
"R1": {
"console_port": 5000,
"tags": [],
"netmiko_device_type": "cisco_ios_telnet",
"default_username": "admin",
"default_password": "admin123",
},
# credentials cleared via PUT arrive as empty strings
"R2": {
"console_port": 5001,
"tags": [],
"netmiko_device_type": "cisco_ios_telnet",
"default_username": "",
"default_password": "",
},
# never seeded
"R3": {
"console_port": 5002,
"tags": [],
"netmiko_device_type": "cisco_ios_telnet",
},
}
}
monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology)
hosts = get_gns3_device_port.get_device_ports_from_topology(["R1", "R2", "R3"])
# set credentials land at host level
assert hosts["R1"]["username"] == "admin"
assert hosts["R1"]["password"] == "admin123"
# cleared ("") and absent credentials do not override the group fallback
assert "username" not in hosts["R2"]
assert "password" not in hosts["R2"]
assert "username" not in hosts["R3"]
assert "password" not in hosts["R3"]

View File

@ -0,0 +1,348 @@
#!/usr/bin/env python
#
# 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
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Tests for device skill topic splitting: directory layout loading
(_base.yaml + topic files) and topic-level retrieval via get_skill /
DeviceSkillsTool.
"""
import pytest
from gns3server.agent.gns3_copilot.skills.loader import SkillsLoader
from gns3server.agent.gns3_copilot.skills.registry import (
DeviceSkillsTool,
get_skill,
)
@pytest.fixture
def skills_dir(tmp_path):
"""
Build a skills directory with both device layouts:
- vpcs.yaml: single-file device (no topics)
- frr/: split device (_base.yaml + ospf/bgp topic files,
one mismatched topic file, one file without a topic field)
- orphan/: directory without _base.yaml (skipped)
"""
device_dir = tmp_path / "device"
device_dir.mkdir()
(device_dir / "vpcs.yaml").write_text(
"""
name: "VPCS"
description: "VPCS test device"
device_type: "gns3_vpcs_telnet"
category: "device"
config_commands:
ip_config:
syntax: "ip <address>/<mask> <gateway>"
description: "Set PC address"
""",
encoding="utf-8",
)
frr_dir = device_dir / "frr"
frr_dir.mkdir()
(frr_dir / "_base.yaml").write_text(
"""
name: "FRR (Free Range Routing)"
description: "FRR test device"
device_type: "frr_vtysh"
category: "device"
config_commands:
write_memory:
syntax: "write memory"
description: "Save config"
""",
encoding="utf-8",
)
(frr_dir / "ospf.yaml").write_text(
"""
device_type: "frr_vtysh"
topic: ospf
name: "OSPF (FRR 10.x)"
description: "OSPF topic"
config_commands:
ospfv2:
syntax: "router ospf"
description: "OSPFv2 process"
""",
encoding="utf-8",
)
(frr_dir / "bgp.yaml").write_text(
"""
device_type: "frr_vtysh"
topic: bgp
name: "BGP (FRR 10.x)"
description: "BGP topic"
config_commands:
bgp_base:
syntax: "router bgp <asn>"
description: "BGP base"
""",
encoding="utf-8",
)
# device_type mismatch with the base -> topic must be skipped
(frr_dir / "mpls.yaml").write_text(
"""
device_type: "other_device_type"
topic: mpls
name: "MPLS"
config_commands:
mpls_base:
syntax: "router mpls"
description: "..."
""",
encoding="utf-8",
)
# no topic field -> falls back to the filename stem
(frr_dir / "static.yaml").write_text(
"""
device_type: "frr_vtysh"
name: "Static routing"
config_commands:
static_routes:
syntax: "ip route <prefix>/<len> <nexthop>"
description: "..."
""",
encoding="utf-8",
)
# directory without _base.yaml -> whole device skipped
orphan_dir = device_dir / "orphan"
orphan_dir.mkdir()
(orphan_dir / "some_topic.yaml").write_text(
"""
device_type: "orphan_device"
topic: anything
name: "Orphan"
""",
encoding="utf-8",
)
return tmp_path
class TestDeviceSkillsLoading:
"""
SkillsLoader.load_device_skills() with single-file and split layouts.
"""
def test_both_layouts_are_loaded(self, skills_dir):
skills = SkillsLoader(str(skills_dir)).load_device_skills()
assert "gns3_vpcs_telnet" in skills
assert "frr_vtysh" in skills
# the orphan directory (no _base.yaml) must not produce an entry
assert "orphan_device" not in skills
assert len(skills) == 2
def test_topics_are_merged_under_topics(self, skills_dir):
skills = SkillsLoader(str(skills_dir)).load_device_skills()
frr = skills["frr_vtysh"]
# base-level content stays at the top level
assert frr["config_commands"]["write_memory"]["syntax"] == "write memory"
assert frr["category"] == "device"
topics = frr["topics"]
assert topics["ospf"]["name"] == "OSPF (FRR 10.x)"
assert topics["bgp"]["config_commands"]["bgp_base"]["syntax"] == "router bgp <asn>"
# file without a topic field falls back to its filename stem
assert "static" in topics
# mismatched device_type is skipped
assert "mpls" not in topics
def test_topic_metadata_is_stripped(self, skills_dir):
skills = SkillsLoader(str(skills_dir)).load_device_skills()
for topic_data in skills["frr_vtysh"]["topics"].values():
assert "device_type" not in topic_data
assert "topic" not in topic_data
assert "category" not in topic_data
assert "topics" not in topic_data
def test_single_file_device_has_no_topics_key(self, skills_dir):
skills = SkillsLoader(str(skills_dir)).load_device_skills()
assert "topics" not in skills["gns3_vpcs_telnet"]
@pytest.fixture
def device_registry():
"""
Populate SKILLS_REGISTRY with a split device and a single-file device,
restoring the previous content afterwards.
"""
from gns3server.agent.gns3_copilot.skills import registry
saved = dict(registry.SKILLS_REGISTRY)
registry.SKILLS_REGISTRY.clear()
registry.SKILLS_REGISTRY.update(
{
"frr_vtysh": {
"name": "FRR (Free Range Routing)",
"description": "FRR test device",
"category": "device",
"config_commands": {"write_memory": {"syntax": "write memory"}},
"topics": {
"ospf": {
"name": "OSPF (FRR 10.x)",
"description": "OSPF topic",
"config_commands": {"ospfv2": {"syntax": "router ospf"}},
},
"bgp": {
"name": "BGP (FRR 10.x)",
"description": "BGP topic",
"config_commands": {"bgp_base": {"syntax": "router bgp <asn>"}},
},
},
},
"gns3_vpcs_telnet": {
"name": "VPCS",
"description": "VPCS test device",
"category": "device",
"config_commands": {"ip_config": {"syntax": "ip <address>/<mask>"}},
},
}
)
yield registry
registry.SKILLS_REGISTRY.clear()
registry.SKILLS_REGISTRY.update(saved)
class TestGetSkillTopics:
"""
Topic-level retrieval and topic index behavior in get_skill().
"""
def test_topic_lookup_returns_topic_body(self, device_registry):
result = get_skill("frr_vtysh", topic="bgp")
assert result["device_type"] == "frr_vtysh"
assert result["skill_name"] == "FRR (Free Range Routing)"
assert result["topic"]["bgp"]["config_commands"]["bgp_base"]["syntax"] == "router bgp <asn>"
def test_topic_lookup_is_case_insensitive(self, device_registry):
result = get_skill("frr_vtysh", topic="BGP")
assert "bgp" in result["topic"]
def test_unknown_topic_lists_available_topics(self, device_registry):
result = get_skill("frr_vtysh", topic="mpls")
assert "error" in result
assert sorted(result["available_topics"]) == ["bgp", "ospf"]
def test_full_without_topic_returns_index_not_bodies(self, device_registry):
result = get_skill("frr_vtysh", detail="full")
# base-level content is included...
assert result["config_commands"]["write_memory"]["syntax"] == "write memory"
# ...but topic bodies are never included without an explicit topic
assert result["topics"] == {
"ospf": "OSPF (FRR 10.x)",
"bgp": "BGP (FRR 10.x)",
}
assert "config_commands" not in result["topics"]["ospf"]
def test_index_includes_topic_index(self, device_registry):
result = get_skill("frr_vtysh", detail="index")
assert result["topics"]["bgp"] == "BGP (FRR 10.x)"
def test_summary_includes_topic_descriptions(self, device_registry):
result = get_skill("frr_vtysh", detail="summary")
assert result["topics"]["ospf"] == {
"name": "OSPF (FRR 10.x)",
"description": "OSPF topic",
}
def test_single_file_device_still_works(self, device_registry):
result = get_skill("gns3_vpcs_telnet")
assert result["config_commands"]["ip_config"]["syntax"] == "ip <address>/<mask>"
assert "topics" not in result
class TestDeviceSkillsToolTopics:
"""
DeviceSkillsTool passes the topic parameter through to get_skill().
"""
def test_tool_topic_request(self, device_registry):
import json
tool = DeviceSkillsTool()
result = json.loads(tool._run('{"device_type": "frr_vtysh", "topic": "ospf"}'))
assert result["topic"]["ospf"]["config_commands"]["ospfv2"]["syntax"] == "router ospf"
def test_tool_list_shows_topic_counts(self, device_registry):
import json
tool = DeviceSkillsTool()
result = json.loads(tool._run('{"action": "list"}'))
by_type = {s["device_type"]: s for s in result["skills"]}
assert by_type["frr_vtysh"]["topic_count"] == 2
assert by_type["gns3_vpcs_telnet"]["topic_count"] == 0
class TestReloadSkillsValidation:
"""
Invalid injection skills are dropped instead of merged into the registry.
"""
def test_invalid_injection_skill_is_dropped(self, tmp_path, monkeypatch):
from gns3server.config import Config
from gns3server.agent.gns3_copilot.skills import registry
from gns3server.agent.gns3_copilot.skills.manager import SkillsManager
# SkillsManager derives its local path from <config_dir>/skills
injection_dir = tmp_path / "skills" / "injection"
injection_dir.mkdir(parents=True)
(injection_dir / "valid.yaml").write_text(
"""
name: "OSPF Issues Injection"
description: "OSPF faults"
category: "injection"
issues:
ospf_area_mismatch:
name: "OSPF Area Mismatch"
description: "Areas differ"
""",
encoding="utf-8",
)
# missing the required "issues" field -> invalid, must be dropped
(injection_dir / "broken.yaml").write_text(
"""
name: "Broken Injection"
description: "No issues field"
""",
encoding="utf-8",
)
monkeypatch.setattr(Config, "config_dir", property(lambda self: str(tmp_path)))
saved_injection = dict(registry.INJECTION_SKILLS_REGISTRY)
saved_skills = dict(registry.SKILLS_REGISTRY)
registry.INJECTION_SKILLS_REGISTRY.clear()
registry.SKILLS_REGISTRY.clear()
try:
manager = SkillsManager(repo_url="https://example.invalid/gns3-skills.git")
manager._repo = None
assert manager.reload_skills() is True
assert "injection_valid" in registry.INJECTION_SKILLS_REGISTRY
assert "injection_broken" not in registry.INJECTION_SKILLS_REGISTRY
finally:
registry.INJECTION_SKILLS_REGISTRY.clear()
registry.INJECTION_SKILLS_REGISTRY.update(saved_injection)
registry.SKILLS_REGISTRY.clear()
registry.SKILLS_REGISTRY.update(saved_skills)

View File

@ -306,6 +306,45 @@ class TestDockerNodesRoutes:
assert response.json()["environment"] == "GNS3=1\nGNS4=0"
assert response.json()["extra_hosts"] == "test:127.0.0.1"
async def test_docker_update_empty_strings_do_not_recreate_container(
self,
app: FastAPI,
compute_client: AsyncClient,
compute_project: Project
) -> None:
"""
Web clients serialize empty form fields as "" while unset values are
stored as None on the node: a full PUT must not see a phantom change
and recreate the container for nothing.
"""
params = {"name": "DOCKER-EMPTY", "image": "nginx", "environment": ""}
with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "nginx"}]):
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value={"Id": "8bd8153ea8f5"}):
response = await compute_client.post(
app.url_path_for("compute:create_docker_node", project_id=compute_project.id), json=params
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["environment"] is None # "" normalized at creation
assert response.json()["console_http_path"] == "/"
node_id = response.json()["node_id"]
with asyncio_patch("gns3server.compute.docker.docker_vm.DockerVM.update") as mock:
response = await compute_client.put(
app.url_path_for("compute:update_docker_node", project_id=compute_project.id, node_id=node_id),
json={
"name": "DOCKER-EMPTY",
"start_command": "",
"environment": "",
"extra_hosts": "",
"console_http_path": "",
},
)
assert response.status_code == 200
assert not mock.called # no real change: the container must not be recreated
assert response.json()["start_command"] is None
assert response.json()["console_http_path"] == "/"
async def test_docker_start_capture(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None:

View File

@ -382,6 +382,7 @@ class TestEthernetSwitchNodesRoutes:
response = await compute_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
# the port's relay bridge and TAP are released from the kernel bridge
br = node._bridge_name
tap = f"{br}-0"
relay = f"{node.id}-0"
@ -390,6 +391,92 @@ class TestEthernetSwitchNodesRoutes:
call(f"bridge delete {relay}"),
])
async def test_ethernet_switch_update_nio(
self,
app: FastAPI,
compute_client: AsyncClient,
compute_project: Project,
ethernet_switch: dict
) -> None:
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0"
)
params = self._udp_params()
params["filters"] = {"delay": [10, 0]}
response = await compute_client.post(url, json=params)
assert response.status_code == status.HTTP_201_CREATED
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
params["filters"] = {"packet_loss": [10]}
params["markers"] = {}
url = app.url_path_for(
"compute:update_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0"
)
response = await compute_client.put(url, json=params)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["filters"] == {"packet_loss": [10]}
# update_nio re-applies the filters on the port's uBridge relay
relay = node._ubridge_bridge_name(0)
node._ubridge_send.assert_any_call(f"bridge reset_packet_filters {relay}")
async def test_ethernet_switch_toggle_marker(
self,
app: FastAPI,
compute_client: AsyncClient,
compute_project: Project,
ethernet_switch: dict
) -> None:
# a marker installed via the NIO registers in the node's filter-bridge map
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0"
)
params = self._udp_params()
params["markers"] = {"icmp": {"bpf": "icmp", "link_id": "link-1", "enabled": True}}
response = await compute_client.post(url, json=params)
assert response.status_code == status.HTTP_201_CREATED
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
url = app.url_path_for(
"compute:toggle_ethernet_switch_marker",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
marker_name="icmp"
)
response = await compute_client.put(url, json={"enabled": False})
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"marker_name": "icmp", "enabled": False}
relay = node._ubridge_bridge_name(0)
node._ubridge_send.assert_any_call(f"bridge enable_packet_filter {relay} icmp off")
# toggling an unknown marker is a 404
url = app.url_path_for(
"compute:toggle_ethernet_switch_marker",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
marker_name="nope"
)
response = await compute_client.put(url, json={"enabled": True})
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_ethernet_switch_start_capture(
self,
app: FastAPI,

View File

@ -0,0 +1,77 @@
#
# Copyright (C) 2020 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
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest
from fastapi import FastAPI, status
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
class TestNetmikoRoutes:
async def test_device_types(self, app: FastAPI, client: AsyncClient) -> None:
"""
Test listing the device types supported by the installed Netmiko library.
"""
pytest.importorskip("netmiko", reason="netmiko is not installed")
import netmiko
response = await client.get(app.url_path_for("get_netmiko_device_types"))
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["netmiko_version"] == netmiko.__version__
device_types = data["device_types"]
assert len(device_types) > 0
names = [entry["name"] for entry in device_types]
assert names == sorted(names)
by_name = {entry["name"]: entry for entry in device_types}
assert by_name["cisco_ios"]["telnet"] is False
assert by_name["cisco_ios"]["custom"] is False
assert by_name["cisco_ios_telnet"]["telnet"] is True
# '_ssh' aliases and the 'autodetect' pseudo device type are filtered out
assert not [name for name in names if name.endswith("_ssh")]
assert "autodetect" not in names
# GNS3-copilot custom drivers are flagged as custom
assert by_name["gns3_vpcs_telnet"]["custom"] is True
assert by_name["gns3_vpcs_telnet"]["telnet"] is True
async def test_device_types_unavailable(self, app: FastAPI, client: AsyncClient, monkeypatch) -> None:
"""
Test that a 501 is returned when Netmiko is not installed.
"""
from gns3server.api.routes.controller import netmiko as netmiko_route
def _raise_import_error():
raise ImportError("No module named 'netmiko'")
monkeypatch.setattr(netmiko_route, "_load_netmiko_device_types", _raise_import_error)
monkeypatch.setattr(netmiko_route, "_device_types_cache", None)
response = await client.get(app.url_path_for("get_netmiko_device_types"))
assert response.status_code == status.HTTP_501_NOT_IMPLEMENTED
# the HTTP exception handler formats errors with a "message" key
assert "ai-features" in response.json()["message"]

View File

@ -146,7 +146,8 @@ class TestTemplateRoutes:
"version": "3.0",
"compute_id": "local",
"template_type": "vpcs",
"tags": ["tag1", "tag2"]
"tags": ["tag1", "tag2"],
"netmiko_device_type": "generic_termserver_telnet"
}
response = await client.post(app.url_path_for("create_template"), json=params)
@ -156,12 +157,59 @@ class TestTemplateRoutes:
assert response.status_code == status.HTTP_200_OK
assert response.json()["template_id"] == template_id
assert response.json()["tags"] == ["tag1", "tag2"]
assert response.json()["netmiko_device_type"] == "generic_termserver_telnet"
params = {"name": "VPCS_TEST_RENAMED", "console_auto_start": True}
params = {"name": "VPCS_TEST_RENAMED", "console_auto_start": True, "netmiko_device_type": "cisco_ios"}
response = await client.put(app.url_path_for("update_template", template_id=template_id), json=params)
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == "VPCS_TEST_RENAMED"
assert response.json()["netmiko_device_type"] == "cisco_ios"
# the field can also be cleared with an empty string
params = {"netmiko_device_type": ""}
response = await client.put(app.url_path_for("update_template", template_id=template_id), json=params)
assert response.status_code == status.HTTP_200_OK
assert response.json()["netmiko_device_type"] == ""
async def test_template_appliance_metadata_roundtrip(self, app: FastAPI, client: AsyncClient) -> None:
"""
Appliance metadata persists with the template: create, read back,
and replace on update. Unknown fields are kept (extra=allow) so that
future appliance registry fields do not vanish.
"""
template_id = str(uuid.uuid4())
params = {
"template_id": template_id,
"name": "VPCS_METADATA",
"compute_id": "local",
"template_type": "vpcs",
"appliance_metadata": {
"vendor_name": "Test vendor",
"default_username": "admin",
"future_field": "kept",
},
}
response = await client.post(app.url_path_for("create_template"), json=params)
assert response.status_code == status.HTTP_201_CREATED
metadata = response.json()["appliance_metadata"]
assert metadata["vendor_name"] == "Test vendor"
assert metadata["default_username"] == "admin"
assert metadata["future_field"] == "kept"
# read back from the database
response = await client.get(app.url_path_for("get_template", template_id=template_id))
assert response.status_code == status.HTTP_200_OK
assert response.json()["appliance_metadata"] == metadata
# the metadata object is replaced as a whole on update
params = {"appliance_metadata": {"default_username": "root"}}
response = await client.put(app.url_path_for("update_template", template_id=template_id), json=params)
assert response.status_code == status.HTTP_200_OK
assert response.json()["appliance_metadata"] == {"default_username": "root"}
async def test_template_delete(self, app: FastAPI, client: AsyncClient) -> None:

View File

@ -106,7 +106,8 @@ class TestRoutes:
async with aconnect_ws(path, client, params=params) as ws:
json_notification = await ws.receive_json()
assert json_notification['event'] == {
'message': 'Could not authenticate while connecting to controller WebSocket: Could not validate credentials'
'message': 'Could not authenticate while connecting to controller WebSocket: '
'Invalid token (DecodeError) (received token sha256 prefix: 4d4f92fb)'
}

View File

@ -24,8 +24,10 @@ These tests cover:
* init.sh prepend being skipped with GNS3_SKIP_INIT;
* GNS3_INTERFACE_NAMES renaming injected interfaces (move_to_ns target);
* the hardcoded /etc/network mount being dropped for SKIP_INIT containers;
* persistent volumes being seeded host-side and bound directly at their
real in-container paths (no post-start bridge racing the NOS boot);
* the docker_exec console dispatch in start();
* the SKIP_INIT volume bridge and container-side _fix_permissions passes.
* the container-side _fix_permissions passes.
"""
import uuid
@ -235,28 +237,36 @@ async def test_create_interface_names_sets_max_ethernet(compute_project, manager
async def test_create_drops_etc_network_for_skip_init(compute_project, manager):
response = _create_response(None, volumes={"/opt/srlinux/appmgr": None})
seed_proc = MagicMock()
seed_proc.communicate = AsyncioMagicMock(return_value=(b"seedcid", b""))
seed_proc.returncode = 0
with asyncio_patch("gns3server.compute.docker.Docker.list_images",
return_value=[{"image": "srlinux"}]):
with asyncio_patch("gns3server.compute.docker.Docker.query",
return_value=response) as mock:
vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project,
manager, "srlinux:latest",
console_type="docker_exec",
environment="GNS3_SKIP_INIT=1",
extra_volumes=["/etc/opt/srlinux"])
await vm.create()
sent = mock.call_args.kwargs["data"]
targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]]
# /etc/network must NOT be mounted
assert "/gns3volumes/etc/network" not in targets
# but the declared volumes ARE mounted
assert "/gns3volumes/opt/srlinux/appmgr" in targets
assert "/gns3volumes/etc/opt/srlinux" in targets
# GNS3_VOLUMES env must also exclude /etc/network
vol_env = [v for v in sent["Env"] if v.startswith("GNS3_VOLUMES=")][0]
assert "/etc/network" not in vol_env
# host skeleton dir removed
assert not os.path.exists(os.path.join(vm.working_dir, "etc", "network"))
with patch("asyncio.subprocess.create_subprocess_exec",
return_value=seed_proc):
vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project,
manager, "srlinux:latest",
console_type="docker_exec",
environment="GNS3_SKIP_INIT=1",
extra_volumes=["/etc/opt/srlinux"])
await vm.create()
sent = mock.call_args.kwargs["data"]
targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]]
# /etc/network must NOT be mounted
assert "/gns3volumes/etc/network" not in targets
assert "/etc/network" not in targets
# the declared volumes are bound DIRECTLY at their real paths —
# no /gns3volumes aliasing and no post-start bridge
assert "/opt/srlinux/appmgr" in targets
assert "/etc/opt/srlinux" in targets
assert not any(t.startswith("/gns3volumes/") for t in targets)
# GNS3_VOLUMES env must also exclude /etc/network
vol_env = [v for v in sent["Env"] if v.startswith("GNS3_VOLUMES=")][0]
assert "/etc/network" not in vol_env
# host skeleton dir removed
assert not os.path.exists(os.path.join(vm.working_dir, "etc", "network"))
@pytest.mark.asyncio
@ -321,7 +331,6 @@ async def test_start_docker_exec_dispatches_console(compute_project, manager):
vm._get_namespace = AsyncioMagicMock(return_value=42)
vm._add_ubridge_connection = AsyncioMagicMock()
vm._start_docker_exec_console = AsyncioMagicMock()
vm._setup_skip_init_volumes = AsyncioMagicMock()
vm._fix_permissions = AsyncioMagicMock()
with patch("gns3server.compute.docker.Docker.install_busybox"):
@ -330,8 +339,8 @@ async def test_start_docker_exec_dispatches_console(compute_project, manager):
vm._start_docker_exec_console.assert_called_once()
assert vm.status == "started"
# SKIP_INIT path runs the volume bridge + permission fix
vm._setup_skip_init_volumes.assert_called_once()
# SKIP_INIT path still runs the permission fix (volumes are already
# seeded and bound at create time — no post-start bridge anymore)
vm._fix_permissions.assert_called_once()
@ -346,19 +355,18 @@ async def test_start_without_skip_init_skips_vendor_passes(compute_project, mana
vm._get_namespace = AsyncioMagicMock(return_value=42)
vm._add_ubridge_connection = AsyncioMagicMock()
vm._start_docker_exec_console = AsyncioMagicMock()
vm._setup_skip_init_volumes = AsyncioMagicMock()
vm._fix_permissions = AsyncioMagicMock()
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
# init.sh runs (no SKIP_INIT) → no vendor bridge/fix passes
vm._setup_skip_init_volumes.assert_not_called()
# init.sh runs (no SKIP_INIT) → no vendor permission pass
vm._fix_permissions.assert_not_called()
# ---------------------------------------------------------------------------
# _fix_permissions — container-side, skips dead containers, targets /gns3volumes
# _fix_permissions — container-side, skips dead containers, targets volume paths
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@ -387,7 +395,7 @@ async def test_fix_permissions_skips_missing_container(compute_project, manager)
@pytest.mark.asyncio
async def test_fix_permissions_targets_gns3volumes(compute_project, manager):
async def test_fix_permissions_targets_volume_paths(compute_project, manager):
vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1")
vm._volumes = ["/etc/opt/srlinux", "/var/log/srlinux"]
@ -404,37 +412,120 @@ async def test_fix_permissions_targets_gns3volumes(compute_project, manager):
await vm._fix_permissions()
# one exec per volume
assert mock_exec.call_count == 2
# each script must target /gns3volumes<volume>, not the raw path
# each script must target the real in-container path (the direct
# bind mount), never the old /gns3volumes alias
for call_obj in mock_exec.call_args_list:
script = call_obj.args[-1] # last positional arg is the sh -c script
assert "/gns3volumes" in script
# must NOT chown the in-container path directly
assert 'chown' in script and '"/gns3volumes' in script
assert "/gns3volumes" not in script
assert '"/etc/opt/srlinux"' in script or '"/var/log/srlinux"' in script
assert 'chown' in script
# ---------------------------------------------------------------------------
# _setup_skip_init_volumes — bridge via docker exec
# _prepare_volumes — host-side seeding (docker create + cp + rm)
# ---------------------------------------------------------------------------
def _seed_proc(stdout=b"seedcid\n", returncode=0):
proc = MagicMock()
proc.communicate = AsyncioMagicMock(return_value=(stdout, b""))
proc.returncode = returncode
return proc
@pytest.mark.asyncio
async def test_setup_skip_init_volumes_runs_exec(compute_project, manager):
async def test_prepare_volumes_seeds_unmarked_volume(compute_project, manager):
vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1",
extra_volumes=["/etc/opt/srlinux"])
vm._volumes = ["/etc/opt/srlinux"]
proc = MagicMock()
proc.communicate = AsyncioMagicMock(return_value=(b"", b""))
proc.returncode = 0
image_info = {"Config": {"Volumes": {}}}
with patch("asyncio.subprocess.create_subprocess_exec",
return_value=proc) as mock_exec:
await vm._setup_skip_init_volumes()
assert mock_exec.call_count == 1
script = mock_exec.call_args.args[-1]
# must do the bind mount
assert "mount --bind" in script
assert "/gns3volumes/etc/opt/srlinux" in script
return_value=_seed_proc()) as mock_exec:
await vm._prepare_volumes(image_info)
# docker create + docker cp + docker rm
assert mock_exec.call_count == 3
argvs = [c.args for c in mock_exec.call_args_list]
assert argvs[0][1:3] == ("create", "srlinux:latest")
assert argvs[1][1:4] == ("cp", "-a", "seedcid:/etc/opt/srlinux/.")
assert argvs[2][1:3] == ("rm", "-f")
host_dir = os.path.join(vm.working_dir, "etc", "opt", "srlinux")
assert os.path.exists(os.path.join(host_dir, ".gns3_perms"))
# a second create() must not re-seed (marker present): no docker CLI call
await vm._prepare_volumes(image_info)
assert mock_exec.call_count == 3
@pytest.mark.asyncio
async def test_prepare_volumes_never_overwrites_marked_volume(compute_project, manager):
"""Regression guard: a volume that ever started (marker present) holds the
node's saved configuration — re-seeding would reset it to factory."""
vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1",
extra_volumes=["/etc/opt/srlinux"])
host_dir = os.path.join(vm.working_dir, "etc", "opt", "srlinux")
os.makedirs(host_dir, exist_ok=True)
marker = os.path.join(host_dir, ".gns3_perms")
open(marker, "w").close()
saved = os.path.join(host_dir, "config.json")
with open(saved, "w") as f:
f.write('{"user": "config"}')
with patch("asyncio.subprocess.create_subprocess_exec",
return_value=_seed_proc()) as mock_exec:
await vm._prepare_volumes({"Config": {"Volumes": {}}})
mock_exec.assert_not_called()
with open(saved) as f:
assert f.read() == '{"user": "config"}'
@pytest.mark.asyncio
async def test_prepare_volumes_tolerates_missing_image_path(compute_project, manager):
"""A volume path the image does not contain (e.g. XRd's /xr-storage-shadow)
starts empty cp fails, the marker is still written, no raise."""
vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1",
extra_volumes=["/xr-storage-shadow"])
calls = {"n": 0}
def proc_factory(*args, **kwargs):
# first call (docker create) succeeds, second (docker cp) fails,
# third (docker rm) succeeds
codes = [0, 1, 0]
proc = _seed_proc(returncode=codes[calls["n"]])
calls["n"] += 1
return proc
with patch("asyncio.subprocess.create_subprocess_exec",
side_effect=proc_factory):
await vm._prepare_volumes({"Config": {"Volumes": {}}})
assert calls["n"] == 3 # rm still ran (finally path)
host_dir = os.path.join(vm.working_dir, "xr-storage-shadow")
assert os.path.exists(os.path.join(host_dir, ".gns3_perms"))
@pytest.mark.asyncio
async def test_prepare_volumes_skips_without_skip_init(compute_project, manager):
vm = _make_vm(compute_project, manager) # no SKIP_INIT
with patch("asyncio.subprocess.create_subprocess_exec",
return_value=_seed_proc()) as mock_exec:
await vm._prepare_volumes({"Config": {"Volumes": {"/etc/opt/srlinux": None}}})
mock_exec.assert_not_called()
@pytest.mark.asyncio
async def test_prepare_volumes_raises_when_seed_container_fails(compute_project, manager):
"""If `docker create` itself fails, creation must abort loudly instead of
binding an empty directory over the NOS's config path."""
vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1",
extra_volumes=["/etc/opt/srlinux"])
proc = _seed_proc(stdout=b"", returncode=1)
with patch("asyncio.subprocess.create_subprocess_exec", return_value=proc):
with pytest.raises(DockerError):
await vm._prepare_volumes({"Config": {"Volumes": {}}})
# ---------------------------------------------------------------------------
@ -463,12 +554,15 @@ def test_cleanup_console_resources_no_writer(compute_project, manager):
# _LazyExecTelnetServer — upstream aliveness + reconnect/recreate logic
# ---------------------------------------------------------------------------
def _make_lazy_server(compute_project, manager):
def _make_lazy_server(compute_project, manager, environment="GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli"):
"""Build a _LazyExecTelnetServer with _create_exec mocked out (no docker)."""
vm = _make_vm(compute_project, manager, environment="GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli")
srv = _LazyExecTelnetServer(vm, manager, "e90e34656842", "/opt/srlinux/bin/sr_cli")
vm = _make_vm(compute_project, manager, environment=environment)
srv = _LazyExecTelnetServer(
vm, manager, "e90e34656842", "/opt/srlinux/bin/sr_cli",
allow_resize=vm._console_resize,
)
srv._create_exec = AsyncioMagicMock()
srv._on_naws = AsyncioMagicMock()
srv._resize_exec = AsyncioMagicMock()
return srv
@ -531,6 +625,74 @@ async def test_first_connect_creates_exec(compute_project, manager):
srv._create_exec.assert_called_once()
@pytest.mark.asyncio
async def test_first_connect_sets_tall_default_pty_geometry(compute_project, manager):
"""The exec PTY must start tall/wide: a 24-row initial geometry makes CLIs
that page on the PTY window size (IOS-XR pager) park at --More-- for
clients that never send NAWS (netmiko, bare telnet)."""
srv = _make_lazy_server(compute_project, manager)
await srv.client_connected_hook()
srv._resize_exec.assert_called_once_with(511, 10000)
@pytest.mark.asyncio
async def test_client_naws_resizes_exec_by_default(compute_project, manager):
"""Client-driven NAWS (WS terminal-size frames) reaches the exec resize."""
srv = _make_lazy_server(compute_project, manager)
await srv._on_naws(120, 40)
srv._resize_exec.assert_called_once_with(120, 40)
@pytest.mark.asyncio
async def test_client_naws_ignored_when_resize_disabled(compute_project, manager):
"""GNS3_CONSOLE_RESIZE=0: client resizes must not change the shared exec
geometry (paging CLIs need the tall default for concurrent netmiko)."""
srv = _make_lazy_server(
compute_project, manager,
environment="GNS3_CONSOLE_RESIZE=0",
)
assert srv._allow_resize is False
await srv._on_naws(120, 40)
srv._resize_exec.assert_not_called()
# the tall default is still applied at exec creation (internal path)
await srv.client_connected_hook()
srv._resize_exec.assert_called_once_with(511, 10000)
@pytest.mark.asyncio
async def test_last_client_disconnect_restores_tall_default(compute_project, manager):
"""When the last console client leaves, the exec goes back to the tall
no-NAWS default so a later non-NAWS client (netmiko) doesn't inherit a
browser geometry and hit PTY-window paging."""
srv = _make_lazy_server(compute_project, manager)
srv._exec_id = "abc"
writer = AsyncioMagicMock()
await srv._disconnect_client(writer)
srv._resize_exec.assert_called_once_with(511, 10000)
@pytest.mark.asyncio
async def test_size_arriving_before_exec_wins_over_default(compute_project, manager):
"""A client size that races the exec creation (WS control frame / NAWS
arriving inside client_connected_hook) must not be overwritten by the
tall default once the exec exists."""
srv = _make_lazy_server(compute_project, manager)
assert srv._exec_id is None
# real _resize_exec (not the mock) records the size when no exec exists
srv._resize_exec = _LazyExecTelnetServer._resize_exec.__get__(srv)
await srv._on_naws(120, 40)
assert srv._client_size == (120, 40)
srv._resize_exec = AsyncioMagicMock()
await srv.client_connected_hook()
srv._resize_exec.assert_called_once_with(120, 40)
@pytest.mark.asyncio
async def test_reconnect_live_exec_not_recreated(compute_project, manager):
"""Reconnecting while the exec is alive must NOT recreate it."""

View File

@ -51,11 +51,16 @@ def test_temporary_directory(compute_project, manager):
assert isinstance(node.temporary_directory, str)
def test_console(compute_project, manager):
def test_console(compute_project, manager, port_manager):
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
node.console = 5011
assert node.console == 5011
# pick a port that is actually free on this host: a hardcoded one may be
# taken by a running gns3server/qemu on a dev machine, and the setter would
# silently replace it with the next free port
console_port = port_manager.get_free_tcp_port(node.project)
port_manager.release_tcp_port(console_port, node.project)
node.console = console_port
assert node.console == console_port
node.console = None
assert node.console is None

View File

@ -155,7 +155,10 @@ def unauthorized_client(base_client: AsyncClient, test_user: User) -> AsyncClien
@pytest_asyncio.fixture(loop_scope="class", scope="class")
def authorized_client(base_client: AsyncClient, test_user: User) -> AsyncClient:
access_token = auth_service.create_access_token(test_user.username)
# Sign with the default secret key: the class-scoped token must stay valid
# across every test, but "run_around_tests" resets the config and forces
# jwt_secret_key back to the default for each test function.
access_token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY)
base_client.headers = {
**base_client.headers,
"Authorization": f"Bearer {access_token}",
@ -168,7 +171,9 @@ async def client(base_client: AsyncClient) -> AsyncClient:
# The super admin is automatically created when the users table is created
# this account that can access all endpoints without restrictions.
access_token = auth_service.create_access_token("admin")
# Sign with the default secret key so the token matches the one enforced
# by "run_around_tests" when the config is reset for each test function.
access_token = auth_service.create_access_token("admin", secret_key=DEFAULT_JWT_SECRET_KEY)
base_client.headers = {
**base_client.headers,
"Authorization": f"Bearer {access_token}",

View File

@ -0,0 +1,210 @@
#!/usr/bin/env python
#
# 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
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import pydantic
import pytest
from gns3server.controller.appliance import Appliance
from gns3server.controller.appliance_to_template import ApplianceToTemplate
from gns3server.schemas.controller.appliances import ApplianceModel
# v8 mirror of the XRd Control Plane appliance shape (docker, custom_adapters, no versions)
XRD_V8 = {
"registry_version": 8,
"appliance_id": "e4a3a5fe-3a13-521b-abd1-ab9483e83aa2",
"name": "XRd Control Plane",
"category": "router",
"description": "Cisco IOS XRd Control Plane",
"vendor_name": "Cisco",
"vendor_url": "https://www.cisco.com/",
"product_name": "XRd Control Plane",
"status": "experimental",
"availability": "service-contract",
"maintainer": "GNS3 Team",
"maintainer_email": "developers@gns3.net",
"usage": "XRd usage",
"symbol": ":/symbols/router.svg",
"settings": [
{
"name": "Default template settings",
"default": True,
"template_type": "docker",
"template_properties": {
"adapters": 24,
"image": "ios-xr/xrd-control-plane:24.4.1",
"console_type": "docker_exec",
"environment": "GNS3_SKIP_INIT=1\nGNS3_CONSOLE_CMD=/pkg/bin/xr_cli.sh",
"extra_volumes": ["/xr-storage"],
"custom_adapters": [
{"adapter_number": 0, "port_name": "MgmtEth0/RP0/CPU0/0"},
{"adapter_number": 1, "port_name": "Gi0/0/0/0"},
],
"extra_configs": [
{"target": "/firstboot.cfg", "content": "!\nend\n"}
],
},
}
],
}
def _appliance(data, builtin=True):
return Appliance("test.gns3a", data, builtin=builtin)
def test_v8_docker_appliance_validates_with_custom_adapters():
# the discriminated union routes to ApplianceV8 and accepts custom_adapters
model = ApplianceModel.model_validate(XRD_V8)
assert model.registry_version == 8
assert model.settings[0].template_properties.custom_adapters[0].port_name == "MgmtEth0/RP0/CPU0/0"
def test_v8_docker_appliance_type():
assert _appliance(XRD_V8).type == "docker"
def test_v8_type_from_default_settings():
# the default set wins over the other sets
appliance = dict(
XRD_V8,
settings=[
{"name": "a", "template_type": "docker", "template_properties": {"image": "test:latest"}},
{"name": "b", "default": True, "template_type": "qemu", "template_properties": {"ram": 512}},
],
)
# the fixture must be a loadable appliance, not an unreachable shape
ApplianceModel.model_validate(appliance)
assert _appliance(appliance).type == "qemu"
def test_v8_qemu_appliance_type_without_default():
appliance = dict(
XRD_V8,
settings=[{"name": "a", "template_type": "qemu", "template_properties": {"ram": 512}}],
)
ApplianceModel.model_validate(appliance)
assert _appliance(appliance).type == "qemu"
def test_v6_docker_appliance_type():
appliance = {
"registry_version": 6,
"name": "v6 docker",
"status": "stable",
"docker": {"image": "test:latest"},
}
assert _appliance(appliance).type == "docker"
def test_v8_docker_install_conversion():
"""
The vendor NOS v8 shape (docker_exec console, env knobs, extra volumes,
custom adapters, extra configs) converts into a docker template.
"""
template = ApplianceToTemplate().new_template(_appliance(XRD_V8).asdict(), None, "local")
assert template["template_type"] == "docker"
assert template["image"] == "ios-xr/xrd-control-plane:24.4.1"
assert template["console_type"] == "docker_exec"
assert template["environment"] == "GNS3_SKIP_INIT=1\nGNS3_CONSOLE_CMD=/pkg/bin/xr_cli.sh"
assert template["extra_volumes"] == ["/xr-storage"]
assert template["custom_adapters"] == [
{"adapter_number": 0, "port_name": "MgmtEth0/RP0/CPU0/0"},
{"adapter_number": 1, "port_name": "Gi0/0/0/0"},
]
assert template["extra_configs"] == [{"target": "/firstboot.cfg", "content": "!\nend\n"}]
def test_v8_docker_settings_require_image():
"""
template_properties must be validated against the model matching
template_type: a docker settings set without the required image is
rejected instead of being silently misrouted to another union member.
"""
appliance = dict(
XRD_V8,
settings=[
{"name": "only", "default": True, "template_type": "docker",
"template_properties": {"adapters": 2}},
],
)
with pytest.raises(pydantic.ValidationError):
ApplianceModel.model_validate(appliance)
def test_v8_template_properties_validated_against_template_type():
"""
Invalid enum values in qemu properties must be rejected at load time,
not silently discarded by a misrouted union member.
"""
appliance = dict(
XRD_V8,
settings=[
{"name": "only", "default": True, "template_type": "qemu",
"template_properties": {"ram": 512, "boot_priority": "zzz"}},
],
)
with pytest.raises(pydantic.ValidationError):
ApplianceModel.model_validate(appliance)
def test_v8_qemu_kvm_property_validates():
appliance = dict(
XRD_V8,
settings=[
{"name": "only", "default": True, "template_type": "qemu",
"template_properties": {"ram": 512, "kvm": "disable"}},
],
)
model = ApplianceModel.model_validate(appliance)
assert model.settings[0].template_properties.kvm == "disable"
def test_v8_qemu_cpu_throttling_range():
"""
cpu_throttling in v8 properties uses the same type and range as the
qemu template (int, 0-800).
"""
settings = {"name": "only", "default": True, "template_type": "qemu",
"template_properties": {"ram": 512, "cpu_throttling": 500}}
model = ApplianceModel.model_validate(dict(XRD_V8, settings=[settings]))
assert model.settings[0].template_properties.cpu_throttling == 500
for bad in (150.5, 900):
bad_settings = dict(settings, template_properties={"ram": 512, "cpu_throttling": bad})
with pytest.raises(pydantic.ValidationError):
ApplianceModel.model_validate(dict(XRD_V8, settings=[bad_settings]))
def test_v8_version_idlepc_validates():
appliance = dict(XRD_V8, versions=[{"name": "1.0", "idlepc": "0x613080c0"}])
ApplianceModel.model_validate(appliance)
bad = dict(XRD_V8, versions=[{"name": "1.0", "idlepc": "not-an-idlepc"}])
with pytest.raises(pydantic.ValidationError):
ApplianceModel.model_validate(bad)
def test_v8_netmiko_device_type_empty_string_clears():
appliance = dict(XRD_V8, netmiko_device_type="")
model = ApplianceModel.model_validate(appliance)
assert model.netmiko_device_type == ""

View File

@ -0,0 +1,154 @@
#!/usr/bin/env python
#
# 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
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import uuid
import pytest
from gns3server.controller.appliance import Appliance
from gns3server.controller.appliance_manager import ApplianceManager
def _v8_appliance(settings, versions=None):
"""
A minimal but fully valid registry version 8 appliance (it must pass
ApplianceModel validation, like appliances loaded by load_appliances).
"""
data = {
"registry_version": 8,
"appliance_id": str(uuid.uuid4()),
"name": "Test appliance",
"category": "router",
"description": "Appliance description",
"vendor_name": "Test vendor",
"vendor_url": "https://example.com/",
"product_name": "Test product",
"status": "stable",
"maintainer": "Test maintainer",
"maintainer_email": "maintainer@example.com",
"symbol": ":/symbols/router.svg",
"settings": settings,
}
if versions:
data["versions"] = versions
return data
class _FakeTemplatesService:
"""
Stands in for TemplatesService so install_appliance can be exercised
without a controller instance or database.
"""
created = []
def __init__(self, templates_repo):
self._templates_repo = templates_repo
async def create_template(self, template_create):
_FakeTemplatesService.created.append(template_create)
return {"name": template_create.name}
@pytest.mark.asyncio
async def test_install_docker_version_skips_image_resolution(monkeypatch):
"""
v8 docker appliances have no image files: installing a version must not
resolve an image directory (default_images_directory does not support
docker) nor iterate the appliance images list.
"""
_FakeTemplatesService.created = []
appliance_data = _v8_appliance(
[
{"name": "default", "default": True, "template_type": "docker",
"template_properties": {"image": "xrd:latest"}},
],
versions=[{"name": "1.0", "images": {"image": "xrd:1.0"}}],
)
manager = ApplianceManager()
appliance = Appliance("test.gns3a", appliance_data)
manager._appliances[appliance.id] = appliance
def _boom(image_type):
raise AssertionError(f"default_images_directory must not be called for docker (got '{image_type}')")
monkeypatch.setattr("gns3server.controller.appliance_manager.default_images_directory", _boom)
monkeypatch.setattr("gns3server.controller.appliance_manager.TemplatesService", _FakeTemplatesService)
await manager.install_appliance(uuid.UUID(appliance.id), "1.0", None, None, None, None)
assert len(_FakeTemplatesService.created) == 1
template = _FakeTemplatesService.created[0].model_dump()
assert template["template_type"] == "docker"
# the version image name is injected into the template
assert template["image"] == "xrd:1.0"
# appliance metadata survives the install and validates through TemplateCreate
metadata = template["appliance_metadata"]
assert metadata["vendor_name"] == "Test vendor"
assert metadata["appliance_id"] == appliance.id
@pytest.mark.asyncio
async def test_install_iou_version_maps_image_to_path(monkeypatch, tmp_path):
"""
v8 IOU versions install: the version image is mapped to the template
path (an IOU template has no 'image' field).
"""
_FakeTemplatesService.created = []
appliance_data = _v8_appliance(
[
{"name": "default", "default": True, "template_type": "iou",
"template_properties": {"ethernet_adapters": 4, "ram": 256}},
],
versions=[{"name": "15.9", "images": {"image": "i86bi-linux-l3-15.9.bin"}}],
)
manager = ApplianceManager()
appliance = Appliance("test.gns3a", appliance_data)
manager._appliances[appliance.id] = appliance
monkeypatch.setattr(
"gns3server.controller.appliance_manager.default_images_directory",
lambda image_type: str(tmp_path),
)
monkeypatch.setattr("gns3server.controller.appliance_manager.TemplatesService", _FakeTemplatesService)
await manager.install_appliance(uuid.UUID(appliance.id), "15.9", None, None, None, None)
assert len(_FakeTemplatesService.created) == 1
template = _FakeTemplatesService.created[0].model_dump()
assert template["template_type"] == "iou"
assert template["path"] == "i86bi-linux-l3-15.9.bin"
assert "image" not in template
@pytest.mark.asyncio
async def test_install_version_not_found(monkeypatch):
manager = ApplianceManager()
appliance_data = _v8_appliance(
[{"name": "only", "default": True, "template_type": "docker",
"template_properties": {"image": "xrd:latest"}}],
versions=[{"name": "1.0", "images": {"image": "xrd:1.0"}}],
)
appliance = Appliance("test.gns3a", appliance_data)
manager._appliances[appliance.id] = appliance
from gns3server.controller.controller_error import ControllerNotFoundError
with pytest.raises(ControllerNotFoundError):
await manager.install_appliance(uuid.UUID(appliance.id), "9.9", None, None, None, None)

View File

@ -0,0 +1,623 @@
#!/usr/bin/env python
#
# 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 Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest
import pydantic
from gns3server.controller.appliance_to_template import ApplianceToTemplate
from gns3server.controller.controller_error import ControllerError
from gns3server.schemas.controller.appliances import ApplianceModel
# reduced mirror of the upstream vyos.gns3a (registry version 8, qemu, 2 settings sets)
VYOS_V8 = {
"registry_version": 8,
"appliance_id": "f82b74c4-0f30-456f-a582-63daca528502",
"name": "VyOS Universal Router",
"category": "router",
"description": "VyOS",
"vendor_name": "VyOS Inc.",
"vendor_url": "https://vyos.io/",
"product_name": "VyOS Universal Router",
"status": "stable",
"maintainer": "VyOS Inc.",
"maintainer_email": "support@vyos.io",
"usage": "appliance usage",
"symbol": "vyos.svg",
"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",
"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.1-kvm-amd64.qcow2",
"version": "1.5.1",
"md5sum": "816ec7c3699a9e4f19e2b8765fd3d7eb",
"filesize": 667549696,
},
{
"filename": "vyos-1.4.5-kvm-amd64.qcow2",
"version": "1.4.5",
"md5sum": "06ccf7e3ed3f948a23c995133b5fbfce",
"filesize": 557645824,
},
],
"versions": [
{
"name": "1.5.1",
"settings": "1.5 x86_64",
"images": {"hda_disk_image": "vyos-1.5.1-kvm-amd64.qcow2"},
},
{
"name": "1.4.5",
"images": {"hda_disk_image": "vyos-1.4.5-kvm-amd64.qcow2"},
},
],
}
def test_v8_netmiko_device_type_copied_to_template():
appliance = dict(VYOS_V8, netmiko_device_type="vyos_ssh")
template = ApplianceToTemplate().new_template(appliance, VYOS_V8["versions"][1], "local")
assert template["netmiko_device_type"] == "vyos_ssh"
def test_v8_netmiko_device_type_validates():
model = ApplianceModel.model_validate(dict(VYOS_V8, netmiko_device_type="vyos_ssh"))
assert model.netmiko_device_type == "vyos_ssh"
with pytest.raises(pydantic.ValidationError):
ApplianceModel.model_validate(dict(VYOS_V8, netmiko_device_type="Not Valid!"))
def test_v8_version_referenced_settings_with_inheritance():
"""
A version referencing a named settings set must select it and inherit
the default set properties (vyos 1.5.1 -> "1.5 x86_64", ram overridden to 8192).
"""
version = VYOS_V8["versions"][0]
template = ApplianceToTemplate().new_template(VYOS_V8, version, "local")
assert template["template_type"] == "qemu"
assert template["version"] == "1.5.1"
# inherited from the default settings
assert template["adapters"] == 10
assert template["adapter_type"] == "virtio-net-pci"
assert template["platform"] == "x86_64"
# overridden by the selected settings
assert template["ram"] == 8192
# appliance level fields
assert template["name"] == "VyOS Universal Router"
assert template["category"] == "router"
assert template["usage"] == "appliance usage"
assert template["symbol"] == "vyos.svg"
# version images are injected
assert template["hda_disk_image"] == "vyos-1.5.1-kvm-amd64.qcow2"
def test_v8_default_settings_selected_when_version_has_no_reference():
"""
A version without a settings reference falls back to the default settings set.
"""
version = VYOS_V8["versions"][1]
template = ApplianceToTemplate().new_template(VYOS_V8, version, "local")
assert template["version"] == "1.4.5"
assert template["ram"] == 2048
assert template["hda_disk_image"] == "vyos-1.4.5-kvm-amd64.qcow2"
def test_v8_version_level_overrides():
"""
category/usage/symbol defined at the version level override the appliance level.
"""
version = dict(VYOS_V8["versions"][1], category="firewall", usage="version usage", symbol="firewall.svg")
template = ApplianceToTemplate().new_template(VYOS_V8, version, "local")
assert template["category"] == "firewall"
assert template["usage"] == "version usage"
assert template["symbol"] == "firewall.svg"
def test_v8_properties_take_precedence_over_version_and_appliance():
"""
Fields defined in template_properties win over the version and appliance levels.
"""
settings = dict(
VYOS_V8["settings"][0],
template_properties=dict(VYOS_V8["settings"][0]["template_properties"], usage="settings usage"),
)
appliance = dict(VYOS_V8, settings=[settings])
version = dict(VYOS_V8["versions"][1], usage="version usage")
template = ApplianceToTemplate().new_template(appliance, version, "local")
assert template["usage"] == "settings usage"
def test_v8_template_properties_name_and_category():
"""
name/category defined in template_properties are used for the template.
"""
settings = dict(
VYOS_V8["settings"][0],
template_properties=dict(VYOS_V8["settings"][0]["template_properties"], name="VyOS 1.4", category="guest"),
)
appliance = dict(VYOS_V8, settings=[settings])
template = ApplianceToTemplate().new_template(appliance, None, "local")
assert template["name"] == "VyOS 1.4"
assert template["category"] == "guest"
def test_v8_multilayer_switch_category_mapping():
settings = dict(
VYOS_V8["settings"][0],
template_properties=dict(VYOS_V8["settings"][0]["template_properties"]),
)
settings["template_properties"].pop("name", None)
appliance = dict(VYOS_V8, category="multilayer_switch", settings=[settings])
template = ApplianceToTemplate().new_template(appliance, None, "local")
assert template["category"] == "switch"
def test_v8_default_symbol_fallback():
"""
Without any symbol, a docker guest gets the docker symbol, other guests the qemu one.
"""
appliance = {
"registry_version": 8,
"name": "Test",
"category": "guest",
"settings": [{"name": "only", "template_type": "docker", "template_properties": {"image": "test:latest"}}],
}
template = ApplianceToTemplate().new_template(appliance, None, "local")
assert template["symbol"] == ":/symbols/docker_guest.svg"
assert template["template_type"] == "docker"
assert template["image"] == "test:latest"
appliance["settings"][0]["template_type"] = "qemu"
appliance["settings"][0]["template_properties"] = {"ram": 512}
template = ApplianceToTemplate().new_template(appliance, None, "local")
assert template["symbol"] == ":/symbols/qemu_guest.svg"
def test_v8_no_inheritance_when_disabled():
settings = dict(
VYOS_V8["settings"][1],
inherit_default_properties=False,
template_properties={"ram": 4096, "adapters": 2},
)
appliance = dict(VYOS_V8, settings=[VYOS_V8["settings"][0], settings])
version = dict(VYOS_V8["versions"][0], settings="1.5 x86_64")
template = ApplianceToTemplate().new_template(appliance, version, "local")
assert template["ram"] == 4096
assert template["adapters"] == 2
# not inherited
assert "adapter_type" not in template
assert "platform" not in template
def test_v8_unknown_settings_reference_raises():
version = dict(VYOS_V8["versions"][0], settings="does not exist")
with pytest.raises(ControllerError, match="Could not find settings 'does not exist'"):
ApplianceToTemplate().new_template(VYOS_V8, version, "local")
def test_v8_multiple_settings_without_default_raises():
appliance = dict(VYOS_V8)
appliance["settings"] = [
dict(VYOS_V8["settings"][0], default=None),
dict(VYOS_V8["settings"][1]),
]
with pytest.raises(ControllerError, match="none is marked as default"):
ApplianceToTemplate().new_template(appliance, VYOS_V8["versions"][1], "local")
def test_v8_single_settings_selected_without_default_flag():
appliance = {
"registry_version": 8,
"name": "Test",
"category": "router",
"settings": [{"name": "only", "template_type": "qemu", "template_properties": {"ram": 1024}}],
}
template = ApplianceToTemplate().new_template(appliance, None, "local")
assert template["ram"] == 1024
assert template["symbol"] == ":/symbols/router.svg"
def test_v6_path_unchanged():
"""
Registry versions 1-6 keep using the top-level emulator blocks (regression check).
"""
appliance = {
"registry_version": 6,
"name": "SRLinux",
"category": "router",
"symbol": ":/symbols/router.svg",
"usage": "v6 usage",
"docker": {
"adapters": 35,
"image": "ghcr.io/nokia/srlinux:latest",
"console_type": "docker_exec",
"environment": "GNS3_SKIP_INIT=1",
},
}
template = ApplianceToTemplate().new_template(appliance, None, "local")
assert template["template_type"] == "docker"
assert template["image"] == "ghcr.io/nokia/srlinux:latest"
assert template["console_type"] == "docker_exec"
assert template["adapters"] == 35
assert template["usage"] == "v6 usage"
def test_v6_netmiko_device_type_copied_to_template():
appliance = {
"registry_version": 6,
"name": "SRLinux",
"category": "router",
"symbol": ":/symbols/router.svg",
"netmiko_device_type": "nokia_srl",
"docker": {"adapters": 35, "image": "ghcr.io/nokia/srlinux:latest"},
}
template = ApplianceToTemplate().new_template(appliance, None, "local")
assert template["netmiko_device_type"] == "nokia_srl"
def test_v8_iou_version_images_mapped_to_path():
"""
An IOU template takes the image as a path: the version image name must be
mapped to 'path', never to an 'image' key.
"""
appliance = {
"registry_version": 8,
"name": "IOU L3",
"category": "router",
"settings": [
{"name": "only", "template_type": "iou",
"template_properties": {"ethernet_adapters": 4, "ram": 256}},
],
}
version = {"name": "15.9", "images": {"image": "i86bi-linux-l3-15.9.bin"}}
template = ApplianceToTemplate().new_template(appliance, version, "local")
assert template["template_type"] == "iou"
assert template["path"] == "i86bi-linux-l3-15.9.bin"
assert "image" not in template
def test_v8_dynamips_idlepc_from_version():
appliance = {
"registry_version": 8,
"name": "Cisco 7200",
"category": "router",
"settings": [
{"name": "only", "template_type": "dynamips",
"template_properties": {"ram": 512, "platform": "c7200"}},
],
}
version = {"name": "12.4", "idlepc": "0x613080c0", "images": {"image": "c7200.bin"}}
template = ApplianceToTemplate().new_template(appliance, version, "local")
assert template["idlepc"] == "0x613080c0"
assert template["image"] == "c7200.bin"
def test_v8_dynamips_settings_idlepc_precedence():
"""
An idlepc defined in template_properties wins over the version level.
"""
appliance = {
"registry_version": 8,
"name": "Cisco 7200",
"category": "router",
"settings": [
{"name": "only", "template_type": "dynamips",
"template_properties": {"ram": 512, "idlepc": "0x6142da40"}},
],
}
version = {"name": "12.4", "idlepc": "0x613080c0", "images": {"image": "c7200.bin"}}
template = ApplianceToTemplate().new_template(appliance, version, "local")
assert template["idlepc"] == "0x6142da40"
def test_v8_qemu_kvm_disable_forces_accel_tcg():
"""
kvm: disable is not a valid template property: it is converted to the
equivalent qemu options like for registry versions 1-6.
"""
appliance = {
"registry_version": 8,
"name": "QEMU VM",
"category": "guest",
"settings": [
{"name": "only", "template_type": "qemu",
"template_properties": {"ram": 512, "options": "-m 512", "kvm": "disable"}},
],
}
template = ApplianceToTemplate().new_template(appliance, None, "local")
assert template["options"] == "-m 512 -machine accel=tcg"
assert "kvm" not in template
def test_v8_qemu_kvm_allow_keeps_options():
appliance = {
"registry_version": 8,
"name": "QEMU VM",
"category": "guest",
"settings": [
{"name": "only", "template_type": "qemu",
"template_properties": {"ram": 512, "options": "-m 512", "kvm": "allow"}},
],
}
template = ApplianceToTemplate().new_template(appliance, None, "local")
assert template["options"] == "-m 512"
assert "kvm" not in template
def test_v8_no_cross_type_inheritance():
"""
A version referencing a docker settings set must not inherit properties
from a default qemu settings set.
"""
appliance = {
"registry_version": 8,
"name": "Mixed",
"category": "router",
"settings": [
{"name": "default qemu", "default": True, "template_type": "qemu",
"template_properties": {"ram": 2048, "adapters": 10}},
{"name": "docker alt", "template_type": "docker",
"template_properties": {"image": "xrd:latest", "adapters": 2}},
],
}
version = {"name": "1.0", "settings": "docker alt", "images": {"image": "xrd:1.0"}}
template = ApplianceToTemplate().new_template(appliance, version, "local")
assert template["template_type"] == "docker"
assert template["adapters"] == 2
assert "ram" not in template
assert template["image"] == "xrd:1.0"
def test_v8_multiple_defaults_inherit_first_same_type():
"""
With several default settings sets of the same type, the first one is
inherited from, matching the selection rule.
"""
appliance = {
"registry_version": 8,
"name": "Multi",
"category": "router",
"settings": [
{"name": "default one", "default": True, "template_type": "qemu",
"template_properties": {"ram": 1024}},
{"name": "default two", "default": True, "template_type": "qemu",
"template_properties": {"ram": 2048}},
{"name": "alt", "template_type": "qemu", "template_properties": {"cpus": 2}},
],
}
version = {"name": "1.0", "settings": "alt"}
template = ApplianceToTemplate().new_template(appliance, version, "local")
assert template["ram"] == 1024
assert template["cpus"] == 2
def test_v8_symbol_fallback_uses_effective_category():
"""
The default symbol must reflect the effective category (template_properties,
then version, then appliance), not the appliance-level one.
"""
appliance = {
"registry_version": 8,
"name": "Test",
"category": "router",
"settings": [
{"name": "only", "template_type": "qemu",
"template_properties": {"ram": 512, "category": "guest"}},
],
}
template = ApplianceToTemplate().new_template(appliance, None, "local")
assert template["category"] == "guest"
assert template["symbol"] == ":/symbols/qemu_guest.svg"
appliance = {
"registry_version": 8,
"name": "Test",
"category": "guest",
"settings": [
{"name": "only", "template_type": "qemu", "template_properties": {"ram": 512}},
],
}
version = {"name": "1.0", "category": "router"}
template = ApplianceToTemplate().new_template(appliance, version, "local")
assert template["category"] == "router"
assert template["symbol"] == ":/symbols/router.svg"
def test_v8_reserved_keys_cannot_be_overridden():
"""
template_properties must not override the structural template fields.
"""
appliance = {
"registry_version": 8,
"name": "Test",
"category": "router",
"settings": [
{"name": "only", "template_type": "docker",
"template_properties": {
"image": "xrd:latest",
"template_type": "qemu",
"compute_id": "evil-compute",
"version": "9.9",
}},
],
}
template = ApplianceToTemplate().new_template(appliance, None, "local")
assert template["template_type"] == "docker"
assert template["compute_id"] == "local"
assert "version" not in template
def test_v8_get_template_type():
"""
get_template_type resolves the emulator type from the settings set
selected for the version, like the install flow does.
"""
converter = ApplianceToTemplate()
appliance = {
"registry_version": 8,
"name": "Mixed",
"category": "router",
"settings": [
{"name": "default", "default": True, "template_type": "qemu",
"template_properties": {"ram": 512}},
{"name": "alt", "template_type": "docker",
"template_properties": {"image": "xrd:latest"}},
],
}
assert converter.get_template_type(appliance, {"name": "1.0", "settings": "alt"}) == "docker"
assert converter.get_template_type(appliance, None) == "qemu"
v6 = {"registry_version": 6, "docker": {"image": "test:latest"}}
assert converter.get_template_type(v6, None) == "docker"
def test_v8_appliance_metadata_copied_to_template():
"""
Appliance metadata (vendor information, default credentials...) is kept
on the template instead of being dropped, with version level values
overriding the appliance level ones.
"""
appliance = dict(VYOS_V8, default_username="vyos", default_password="vyospass")
version = dict(VYOS_V8["versions"][1], default_username="vyos145")
template = ApplianceToTemplate().new_template(appliance, version, "local")
metadata = template["appliance_metadata"]
assert metadata["appliance_id"] == VYOS_V8["appliance_id"]
assert metadata["vendor_name"] == "VyOS Inc."
assert metadata["status"] == "stable"
# the version level default_username overrides the appliance level one
assert metadata["default_username"] == "vyos145"
assert metadata["default_password"] == "vyospass"
def test_v8_appliance_metadata_version_level_overrides():
appliance = dict(VYOS_V8, installation_instructions="appliance instructions")
version = dict(VYOS_V8["versions"][1], installation_instructions="version instructions")
template = ApplianceToTemplate().new_template(appliance, version, "local")
assert template["appliance_metadata"]["installation_instructions"] == "version instructions"
def test_v6_appliance_metadata_copied_to_template():
"""
Registry versions 1-6 appliances keep their metadata too (the fields
they have: the v8-only ones are simply absent).
"""
appliance = {
"registry_version": 6,
"name": "SRLinux",
"category": "router",
"description": "Nokia SR Linux",
"vendor_name": "Nokia",
"docker": {"adapters": 35, "image": "ghcr.io/nokia/srlinux:latest"},
}
template = ApplianceToTemplate().new_template(appliance, None, "local")
metadata = template["appliance_metadata"]
assert metadata["description"] == "Nokia SR Linux"
assert metadata["vendor_name"] == "Nokia"
assert "default_username" not in metadata
def test_no_appliance_metadata_when_appliance_has_none():
appliance = {
"registry_version": 8,
"name": "Test",
"category": "guest",
"settings": [{"name": "only", "template_type": "qemu", "template_properties": {"ram": 512}}],
}
template = ApplianceToTemplate().new_template(appliance, None, "local")
assert "appliance_metadata" not in template

View File

@ -15,13 +15,22 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys
import json
import asyncio
import aiohttp
import pytest
from types import SimpleNamespace
from unittest.mock import patch, MagicMock
from gns3server.controller.project import Project
from gns3server.controller.compute import Compute
from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError, ComputeConflictError
from gns3server.controller.controller_error import (
ControllerError,
ControllerNotFoundError,
ControllerUnauthorizedError,
ComputeConflictError,
)
from pydantic import SecretStr
from tests.utils import asyncio_patch, AsyncioMagicMock
@ -524,3 +533,100 @@ async def test_get_ip_on_same_subnet(controller):
},
]
assert await compute1.get_ip_on_same_subnet(compute2) == ('192.168.2.1', '192.168.1.2')
class FakeWebSocket:
"""
Minimal aiohttp WebSocketResponse stand-in for notification stream tests.
"""
def __init__(self, frames):
self._frames = list(frames)
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
def __aiter__(self):
return self
async def __anext__(self):
if not self._frames:
raise StopAsyncIteration
return self._frames.pop(0)
def _text_frame(payload):
return SimpleNamespace(type=aiohttp.WSMsgType.TEXT, data=json.dumps(payload))
@pytest.mark.asyncio
async def test_connect_notification_poison_frame_autoreconnects(compute, monkeypatch):
"""
A malformed frame must not permanently kill the notification stream: the
error is logged, clients are notified and a reconnection is scheduled.
"""
emit_mock = MagicMock()
monkeypatch.setattr(compute._controller.notification, "controller_emit", emit_mock)
frames = [
_text_frame({"action": "ping", "event": {"cpu_usage_percent": 10.0, "memory_usage_percent": 20.0, "disk_usage_percent": 30.0}}),
_text_frame({"event": {"poison": True}}), # missing "action": raises KeyError in the receive loop
]
session = MagicMock()
session.closed = False
session.ws_connect = MagicMock(return_value=FakeWebSocket(frames))
compute._http_session = session
# allow the reconnection to be scheduled during the test
monkeypatch.delattr(sys, "_called_from_test", raising=False)
from gns3server.api.server import app as gns3_app
monkeypatch.setattr(gns3_app.state, "exiting", False)
async def fake_connect():
compute._reconnect_attempted = True
monkeypatch.setattr(compute, "connect", fake_connect)
# must not raise despite the poison frame
await compute._connect_notification()
actions = [c.args[0] for c in emit_mock.call_args_list]
assert actions.count("compute.updated") >= 2 # one for the ping, one for the disconnect
assert compute._connected is False
# the reconnection scheduled by the finally block fires after 1 second
await asyncio.sleep(1.2)
assert compute._reconnect_attempted is True
@pytest.mark.asyncio
async def test_connect_http_error_notifies_schedules_retry_and_raises(compute, monkeypatch):
"""
HTTP-level failures (401/403/404...) reach connect() as ControllerError
subclasses. They must notify clients, schedule a retry and still raise for
explicit callers. They used to silently kill the fire-and-forget connect()
task started at controller startup: no notification, no retry.
"""
compute._connected = False
emit_mock = MagicMock()
monkeypatch.setattr(compute._controller.notification, "controller_emit", emit_mock)
async def raise_unauthorized(*args, **kwargs):
raise ControllerUnauthorizedError("Invalid authentication for compute 'my_compute_id'")
monkeypatch.setattr(compute, "_run_http_query", raise_unauthorized)
monkeypatch.delattr(sys, "_called_from_test", raising=False)
scheduled_delays = []
monkeypatch.setattr(asyncio.get_event_loop(), "call_later", lambda delay, callback: scheduled_delays.append(delay))
with pytest.raises(ControllerUnauthorizedError):
await compute.connect()
assert compute._last_error == "Invalid authentication for compute 'my_compute_id'"
assert compute.connected is False
actions = [c.args[0] for c in emit_mock.call_args_list]
assert "compute.updated" in actions
assert scheduled_delays == [5] # first exponential backoff delay

View File

@ -378,9 +378,9 @@ async def test_available_filters(project, compute):
link.create = AsyncioMagicMock()
assert link.available_filters() == []
# Ethernet switch is not supported should return 0 filters
# The brctl Ethernet switch hosts filters on its per-port uBridge relays
await link.add_node(node1, 0, 4)
assert link.available_filters() == []
assert len(link.available_filters()) > 0
node2 = Node(project, compute, "node2", node_type="vpcs")
node2._ports = [EthernetPort("E0", 0, 0, 4)]

View File

@ -56,20 +56,21 @@ def _valid_bpf():
return stack
async def _make_link(project, port_cls=EthernetPort):
"""Build a created UDPLink between two VPCS nodes on a mocked compute.
async def _make_link(project, port_cls=EthernetPort, node_types=("vpcs", "vpcs")):
"""Build a created UDPLink between two nodes on a mocked compute.
``port_cls`` defaults to EthernetPort; pass SerialPort for a serial link
(the link's link_type follows the port).
(the link's link_type follows the port). ``node_types`` overrides the
endpoint node types (e.g. a switch-to-switch link).
"""
compute = MagicMock()
compute.id = "local"
compute.host = "example.com"
node1 = Node(project, compute, "n1", node_type="vpcs")
node1 = Node(project, compute, "n1", node_type=node_types[0])
node1._ports = [port_cls("E0", 0, 0, 0)]
node2 = Node(project, compute, "n2", node_type="vpcs")
node2 = Node(project, compute, "n2", node_type=node_types[1])
node2._ports = [port_cls("E0", 0, 0, 1)]
async def subnet(_other):
@ -112,6 +113,23 @@ async def test_start_marker_stores_entry(project):
assert entry["highlight_duration"] == 800
assert entry["enabled"] is True
assert entry["capture_node_id"] in {n["node"].id for n in link._nodes}
@pytest.mark.asyncio
async def test_start_marker_on_ethernet_switch_link(project):
"""A switch-to-switch link can host a marker: the brctl Ethernet switch
runs a per-port uBridge relay the `mark` filter attaches to."""
with _valid_bpf():
link = await _make_link(project, node_types=("ethernet_switch", "ethernet_switch"))
await link.start_marker("icmp", "icmp")
entry = link.markers["icmp"]
switch_ids = {n["node"].id for n in link._nodes}
assert entry["capture_node_id"] in switch_ids
# the marker rides exactly one side's NIO and is pushed via update()
carrying = [d for d in link._link_data if "icmp" in d["markers"]]
assert len(carrying) == 1
assert "inherited_from" not in entry

View File

@ -142,6 +142,9 @@ def test_json(node, compute):
"tags": [],
"custom_adapters": [],
"console_auto_start": False,
"netmiko_device_type": None,
"default_username": None,
"default_password": None,
"ports": [
{
"adapter_number": 0,
@ -179,6 +182,9 @@ def test_json(node, compute):
"custom_adapters": [],
"tags": [],
"console_auto_start": False,
"netmiko_device_type": None,
"default_username": None,
"default_password": None,
}
@ -383,6 +389,84 @@ async def test_update_only_controller(node, compute):
assert not node._project.emit_notification.called
@pytest.mark.asyncio
async def test_update_netmiko_device_type(node, compute):
"""
netmiko_device_type is a controller-only property: updating it must not
call the compute and must be visible in the node json.
"""
compute.put = AsyncioMagicMock()
node._project.emit_notification = AsyncioMagicMock()
node._project.dump = MagicMock()
await node.update(netmiko_device_type="cisco_ios_telnet")
assert not compute.put.called
assert node.netmiko_device_type == "cisco_ios_telnet"
assert node.asdict()["netmiko_device_type"] == "cisco_ios_telnet"
# the field can also be cleared with an empty string
await node.update(netmiko_device_type="")
assert node.netmiko_device_type == ""
assert node.asdict()["netmiko_device_type"] == ""
def test_netmiko_device_type_from_template_kwargs(compute, project):
"""
A node created with the template properties as kwargs inherits
netmiko_device_type without sending it to the compute.
"""
node = Node(project, compute, "test", node_type="vpcs", netmiko_device_type="nokia_srl")
assert node.netmiko_device_type == "nokia_srl"
# controller-only: must not leak into the compute properties
assert "netmiko_device_type" not in node.properties
assert node.asdict(topology_dump=True)["netmiko_device_type"] == "nokia_srl"
@pytest.mark.asyncio
async def test_update_default_credentials(node, compute):
"""
default_username/default_password are controller-only properties: updating
them must not call the compute and must be persisted in the node json.
"""
compute.put = AsyncioMagicMock()
node._project.emit_notification = AsyncioMagicMock()
node._project.dump = MagicMock()
await node.update(default_username="admin", default_password="secret")
assert not compute.put.called
assert node.default_username == "admin"
assert node.default_password == "secret"
assert node.asdict()["default_username"] == "admin"
assert node.asdict(topology_dump=True)["default_password"] == "secret"
# credentials never leak into the compute properties
assert "default_username" not in node.properties
assert "default_password" not in node.properties
# both fields can be cleared with an empty string
await node.update(default_username="", default_password="")
assert node.default_username == ""
assert node.default_password == ""
def test_default_credentials_from_template_kwargs(compute, project):
"""
A node created from a template with appliance metadata inherits the
default credentials without sending them to the compute.
"""
node = Node(project, compute, "test", node_type="vpcs",
default_username="root", default_password="cisco123")
assert node.default_username == "root"
assert node.default_password == "cisco123"
assert "default_username" not in node.properties
assert "default_password" not in node.properties
assert node.asdict(topology_dump=True)["default_username"] == "root"
@pytest.mark.asyncio
async def test_update_no_changes(node, compute):
"""

View File

@ -204,6 +204,48 @@ async def test_add_node_local(controller):
project.emit_notification.assert_any_call("node.created", node.asdict())
@pytest.mark.asyncio
async def test_add_node_from_template_seeds_default_credentials(controller):
"""
The appliance metadata stays template level: creating a node from a
template seeds the default credentials on the node and must not leak
the metadata into the node properties sent to the compute.
"""
compute = MagicMock()
compute.id = "local"
controller._computes["local"] = compute
project = Project(controller=controller, name="Test")
project.emit_notification = MagicMock()
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
template = {
"name": "VPCS_TEST",
"template_type": "vpcs",
"compute_id": "local",
"default_name_format": "PC{0}",
"properties": {"startup_script": "test.cfg"},
"appliance_metadata": {
"vendor_name": "Test vendor",
"default_username": "admin",
"default_password": "secret",
},
}
node = await project.add_node_from_template(template)
# credentials seeded from the appliance metadata
assert node.default_username == "admin"
assert node.default_password == "secret"
# the metadata itself never reaches the node properties
assert "appliance_metadata" not in node.properties
assert "default_username" not in node.properties
assert "default_password" not in node.properties
@pytest.mark.asyncio
async def test_add_node_non_local(controller):
"""

View File

@ -451,6 +451,55 @@ async def test_update(project):
}, timeout=120)
@pytest.mark.asyncio
async def test_update_ethernet_switch_nio(project):
"""
Link updates must reach an Ethernet switch endpoint: the brctl switch has
a PUT NIO route, so only the Dynamips-hosted hub side stays skipped.
"""
compute1 = MagicMock()
node_vpcs = Node(project, compute1, "node1", node_type="vpcs")
node_vpcs._ports = [EthernetPort("E0", 0, 0, 4)]
node_switch = Node(project, compute1, "node2", node_type="ethernet_switch")
node_switch._ports = [EthernetPort("E0", 0, 3, 1)]
async def subnet_callback(compute2):
return ("192.168.1.1", "192.168.1.2")
compute1.get_ip_on_same_subnet.side_effect = subnet_callback
async def compute1_callback(path, data={}, **kwargs):
if "/ports/udp" in path:
response = MagicMock()
response.json = {"udp_port": 1024}
return response
compute1.post.side_effect = compute1_callback
compute1.put = AsyncioMagicMock()
compute1.host = "example.com"
link = UDPLink(project)
await link.add_node(node_vpcs, 0, 4)
await link.add_node(node_switch, 3, 1)
assert link.created
await link.update_filters({"delay": [10, 0]})
compute1.put.assert_any_call(
"/projects/{}/ethernet_switch/nodes/{}/adapters/3/ports/1/nio".format(project.id, node_switch.id),
data={
"lport": 1024,
"rhost": "192.168.1.1",
"rport": 1024,
"type": "nio_udp",
"suspend": False,
"markers": {},
"filters": {}
}, timeout=221
)
@pytest.mark.asyncio
async def test_update_suspend(project):
compute1 = MagicMock()

View File

@ -0,0 +1,89 @@
#
# 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
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import time
import asyncio
import pytest
from gns3server.utils.notification_queue import NotificationQueue
async def _feed(queue, until, interval=0.02):
"""
Continuously put dummy events on the queue to simulate sustained load
(e.g. high marker.match rates).
"""
seq = 0
while time.monotonic() < until:
queue.put_nowait(("dummy", {"seq": seq}, {}))
seq += 1
await asyncio.sleep(interval)
@pytest.mark.asyncio
async def test_first_get_returns_ping():
queue = NotificationQueue()
action, event, _ = await asyncio.wait_for(queue.get(1), 1)
assert action == "ping"
assert "cpu_usage_percent" in event
@pytest.mark.asyncio
async def test_idle_queue_pings_after_timeout():
queue = NotificationQueue()
await queue.get(0.3) # consume the first immediate ping
start = time.monotonic()
action, _, _ = await asyncio.wait_for(queue.get(0.3), 1)
assert action == "ping"
assert time.monotonic() - start >= 0.25 # had to wait for the idle timeout
@pytest.mark.asyncio
async def test_ping_not_starved_under_sustained_load():
"""
Regression test: a continuously-fed queue must still emit a ping at least
every `timeout` seconds. The old idle-timeout-only ping never fired under
sustained event load, so clients stopped receiving compute statistics
(no more compute.updated events) until the event flow paused.
"""
queue = NotificationQueue()
action, _, _ = await queue.get(0.5) # consume the first immediate ping
assert action == "ping"
until = time.monotonic() + 2.0
producer = asyncio.create_task(_feed(queue, until))
try:
pings = 0
events = 0
deadline = time.monotonic() + 2.5
while time.monotonic() < deadline:
action, _, _ = await asyncio.wait_for(queue.get(0.5), 1)
if action == "ping":
pings += 1
else:
events += 1
# real events still flow...
assert events > 0
# ...and pings interleave roughly every 0.5s instead of starving
assert pings >= 2
finally:
producer.cancel()