1703 Commits

Author SHA1 Message Date
YueGuobin
84e7ead465
fix: make node working directory deletion robust
The rmtree error handler in BaseNode.delete() was chmod'ing the failed
path to S_IWRITE (0o200). On POSIX this strips the search permission
from directories, turning a transient deletion failure into a directory
that can no longer be traversed or deleted. The node deletion itself
silently "succeeds" (rmtree gives up once its error handler returns)
and a later project deletion then fails with EACCES. The handler also
never retried the failed operation, so it did not help on Windows
either (the platform it was written for).

The transient failure exists in practice: a concurrent MD5 checksum
computation caching its result in the node directory (e.g. a properties
request racing the deletion) can recreate a file after rmtree has
listed the directory, making the final rmdir fail with ENOTEMPTY.

- add the missing user permissions instead of replacing the whole mode,
  and retry the failed unlink/rmdir
- retry the whole deletion a few times to absorb files recreated while
  the directory is being deleted
- raise a ComputeError when the directory cannot be fully deleted
  instead of failing silently
2026-09-15 01:23:31 +08:00
YueGuobin
be62e8c022
feat: keep Docker images on computes consistent with the controller host
Only relying on the image name lets a moved tag (e.g. a newer :latest)
silently serve stale content from a compute that already has an image
under the same name. When creating a Docker node, the controller now
pins the image id (Id from the Docker daemon on the controller host)
into the create payload. A compute holding a different image under the
same tag reports the image as missing, which routes it through the
image sync added by the previous commit and re-aligns the tag.

No new template fields or database changes: the controller host daemon
remains the source of truth and the pin is resolved per creation. When
the image is not available on the controller host the pin is omitted
and behavior is unchanged (the compute pulls from the repository).
2026-09-14 00:55:18 +08:00
YueGuobin
c477812332
feat: sync Docker images from the controller to remote computes
When a Docker node is created on a remote compute whose Docker daemon
does not have the image, the compute now raises ImageMissingError
instead of blindly pulling from the Docker repository. The controller
exports the image from the Docker daemon on its host (docker save
stream) and streams it to the compute which loads it, so locally built
or docker-loaded images work across computes. When the image is not
available on the controller host either, the compute is asked to pull
it from the Docker repository as a fallback.

- add a POST /docker/images/load compute endpoint that streams a
  docker save tar into the Docker daemon
- let Docker.http_query pass raw (non-dict) request bodies through so
  the tar can be streamed to the daemon
- drop the inline pull from DockerVM.create() and the now unused
  DockerVM.pull_image wrapper
2026-09-14 00:36:54 +08:00
YueGuobin
4b239cc11a
fix: run the reclaim helper as root regardless of the image's default USER
The one-shot reclaim container inherited the image's baked-in USER:
ghcr.io/nokia/srlinux runs as "user:user", so the "privileged" helper
was exactly as unprivileged as the server itself — chmod/chown on files
written by other uids (srlinux writes as a large internal uid) failed
with EPERM and node/project deletion still broke, just with a different
error. Pass --user 0:0 explicitly so the helper is root no matter what
the image declares, and fix the manual reclaim hint the same way.

Validated live on two stuck srlinux node directories (257/258
foreign-owned entries reclaimed to 0 in ~0.5 s each).
2026-09-12 23:15:52 +08:00
YueGuobin
54d9d7c08f
fix: reclaim root-owned container files so docker nodes and projects can be deleted
The stop-time permission pass necessarily runs before the container's
processes exit, so files written during the shutdown window (syslog
archives, trace flushes) and after any SIGKILL path stay owned by root
on the host. An unprivileged server can neither chown nor delete them,
which broke node deletion and project deletion.

Reclaim them through the only privilege door a non-root server has:
a one-shot throwaway container of the node's own image, entrypoint
overridden to the GNS3 busybox (nothing of the guest boots), chowning
the node directory back to the server user. It runs at the end of
close() — project deletion rmtrees the directory right after the nodes
close, so close must leave a clean tree — and as a retry fallback in
delete(). The helper resolves the image by its create-time ID with
--pull=never, so a stale or retagged image name cannot turn into a
registry pull attempt.
2026-09-12 23:04:28 +08:00
YueGuobin
673253d848
ci: install sharkd in the test workflow and make manager tests hermetic
The replay tests marked sharkd_present skip gracefully without the
binary, but four manager-level tests (cap eviction, in-use protection,
single spawn under concurrency, filter-error mapping) run entirely
against fakes and died on the acquire-time PATH check instead - install
wireshark-common (which ships /usr/bin/sharkd on the Ubuntu runner) so
the real-engine tests actually run in CI, and patch the which() check in
the fake-session tests so the suite stays green on machines without
sharkd.
2026-09-11 23:49:51 +08:00
YueGuobin
5d91ca0efc
fix: harden sharkd replay sessions and serve the uncapped frame list
Review-driven session/transport fixes (each reproduced live against
sharkd 4.6.7 before fixing):

- raise the RPC stream limit to 16 MB: a full 1000-row frames page
  measures ~190 KB against the 64 KB StreamReader default, which failed
  the request with a 500 and desynchronized the resident session; a
  line-over-limit ValueError is now treated as a transport failure
- verify JSON-RPC reply ids: a timed-out request's late reply was
  served as the next request's answer; timeouts, dead pipes, malformed
  and stale replies now kill the session for good instead
- make check-spawn atomic under one manager lock: concurrent requests
  for the same pcap double-spawned sharkd and leaked the loser (process
  plus /tmp scratch copy) forever
- refcount sessions and evict idle only (LRU, cap raised 8 -> 16): a
  tag with more sources than the cap respawned every source on every
  request, and concurrent requests could get their session killed
  mid-RPC (spurious 502)
- map FilterError to sharkd's filter rejection (-13002) only; other
  engine failures with a filter set are 502, not a client 400
- detail: accept an optional frame_number to disambiguate
  same-microsecond frames (ts is not unique within a pcap); drop the
  -8003 -> 404 mapping (the range is validated locally, engine errors
  are real faults); a failed hex read is a 404 instead of "hex": null
- a pcap deleted mid-request is a 404, not a 500; the pcap-sized
  scratch copy runs off the event loop; server shutdown kills every
  resident session and drops its scratch directory
- pin the packet-list layout through scratch-HOME Wireshark
  preferences: the column indexes are a contract the server owns
  (protocol-level column negotiation is rejected by sharkd 4.6.x)

Range contract change (WebUI moved to an always-flat list): the merged
frame list is returned in full, deliberately uncapped - truncated and
per-second buckets are removed, frame_count always equals
len(frames), and rendering cost is the client's concern (the window
endpoint remains the incremental path).
2026-09-11 00:55:18 +08:00
YueGuobin
ef5d81498a
feat: accept link=<link_id> on the replay range and frames endpoints
Narrows the merged frame stream to one capture source BEFORE counting,
slicing and bucketing (frame_count / frames | buckets all recomputed on
the narrowed set), AND-composing with the display filter. A pure
identity filter applied before any engine work — only the selected
link's pcap gets a sharkd pass, so link+filter is cheaper than filter
alone.

Two boundaries by contract with the Web UI:
- sources[] stays the tag's stable inventory: every capture source
  listed with engine-free TOTAL counts, unaffected by link/filter — a
  source dropdown must not shrink when the view narrows (this also
  settles sources[].count on total counts rather than post-filter
  matches, which no spec ever required)
- an unknown link_id matches nothing: frame_count 0, start null, empty
  frames/buckets — the same shape as a zero-match display filter,
  deliberately not a 404; an empty link param is treated as absent
2026-09-09 00:53:06 +08:00
YueGuobin
790c423c26
feat: drive marker replay with resident sharkd sessions
sharkd (the Wireshark daemon) is now the single decode engine — the
tshark/PDML path is gone, and without sharkd every replay endpoint
returns 501 (no degraded mode: one engine, one rendering shape for the
Web UI).

- Frame entries gain packet-list columns from sharkd's frames RPC:
  src/dst/proto/info plus the Wireshark coloring hints bg/fg
- range and frames accept ?filter=<display filter>, applied before
  counting and slicing; invalid expressions are 400 carrying sharkd's
  original text; filters travel as single argv-style elements, capped
  at 2000 chars; filtered frames keep their original pcap frame numbers
- frame detail returns sharkd's protocol tree with keys renamed into
  the REST contract (element/label/name/filter_expr/pos+size/expert/
  generated/children): a census-verified closed key set, values
  untouched, unknown keys passed through verbatim, Wireshark-internal
  hf ids dropped. filter_expr gives the UI click-to-filter; pos/size
  drives hex highlighting (hex still read straight from the pcap)
- one resident 'sharkd -' session per source pcap: lazy spawn, /tmp
  scratch copy + scratch HOME (hardened profiles), per-request
  (mtime,size) validation with respawn, LRU bound, per-session lock,
  per-RPC timeout, bounded close

Timeline backbone (gate, record-header scan, merge ordering, canonical
ts strings, hex reads) stays plain Python — identity and ordering never
depend on the engine.
2026-09-08 00:04:04 +08:00
YueGuobin
0c540abbf2
fix: stop leaking a global os.kill mock from the shutdown route test
The bare 'os.kill = MagicMock()' was never restored, so every later test
in the same process ran with a no-op kill. Any test that kills a child
process and waits for it then hangs forever (resident sharkd sessions
waiting on an immortal process). Use monkeypatch so the patch is undone.
2026-09-08 00:04:04 +08:00
YueGuobin
a8c96dd3f7
fix: keep port_number in Docker NIO dispatch of the batch endpoints
The project-open bulk path (_add_nio_binding / _get_existing_nio /
_update_nio_binding in routes/compute/projects.py) dropped port_number
for Docker nodes, unlike the per-node routes and the IOU branch. With
multi-port docker adapters (iol-runner nodes model 4 ports per adapter,
0ca9ccc63) every batched NIO landed on port 0 where Adapter.add_nio()
silently overwrites — the last entry per node won — so reopening a
project clobbered the port-0 links with the port-1 NIOs: links ended up
cross-wired between the wrong node pairs and the real links died
(IOL direct-link ping failures after close/reopen, EXCESSCOLL storms
from the phantom loops).
2026-09-06 00:15:43 +08:00
YueGuobin
27b55b72f9
fix: keep upstream link-carrier commands off unix-socket NIO bridges
Upstream #2870 added TAP carrier control (set_nio_tap_carrier) and a
busybox interface-status monitor to the Docker base class. Both assume
TAP wiring: unix-socket NIO bridges carry no TAP NIO, so uBridge rejects
the carrier command ('bridge has no TAP NIO') and every link create,
update or delete on a running iol-runner node would fail; the monitor
polls eth{adapter} interfaces that do not exist in the container's
network namespace (or misreports the Docker default eth0 as adapter 0).

VendorDockerVM now short-circuits _set_adapter_carrier and the interface
monitor under GNS3_UNIX_SOCKET_NIO; TAP-wired vendor nodes keep the base
behavior.
2026-09-05 22:21:18 +08:00
YueGuobin
e12ef7f272
tests: expect port_number in adapter carrier calls
Upstream #2870's carrier tests assert the two-argument
_set_adapter_carrier call; the 4-port-unit commit threads port_number
through every carrier call site, so single-port adapters now pass 0.
2026-09-05 21:58:45 +08:00
YueGuobin
e318afef86
feat: ship an iol-xe-base.txt base config for IOL Docker nodes
Follows the IOU pattern: the file lives in gns3server/configs/ and the
controller installs it into the user's configs directory on startup
(never overwriting a customized copy). Referenced by the documented
GNS3_IOL_STARTUP_CONFIG=iol-xe-base.txt template knob — without it a
fresh install had no file for the knob to resolve and nodes booted to
the initial configuration dialog.

Also fixes the configs directory named in the docs (~/GNS3/configs,
the configs_path server setting default — not ~/.config/GNS3/...).
2026-09-05 21:54:52 +08:00
YueGuobin
5685f40dd6
fix: keep payload-delivered state across container (re)creation
create() re-runs _parse_vendor_environment on every (re)create — and a
stop removes the container, so every start recreates it. Resetting the
controller-allocated application id there silently flipped nodes to the
hash fallback after their first stop/start (MAC change, plus collision
risk with the allocation pool), and dropped any pending startup-config
delivered by a PUT.

The application id and startup-config state now default via class
attributes instead of being re-initialized by the parser, and the hash
fallback is removed outright: starting an IOL node without a coordinated
allocation raises an actionable error — an uncoordinated id could
collide with the pool and blackhole traffic as a MAC loop.

Found in the E2E run: node booted app id 512, came back as 566 (hash
fallback) after one stop/start; the PUT-ed config edit vanished the same
way.
2026-09-05 21:54:52 +08:00
YueGuobin
8b3aafbbdc
feat: IOU-style startup-config for IOL Docker nodes
Templates reference a config file with the GNS3_IOL_STARTUP_CONFIG
environment knob; the controller materializes the file content into
startup_config_content on node creation (sent once, knob consumed —
the same pattern as the IOU startup_config mapping). The compute builds
the content into the node's nvram_<app id> at the next start using the
IOU nvram_import utility (IOL and IOU share the nvram container format,
verified against iol-xe 17.18.02): valid config at boot, no setup
dialog, %h hostname substitution, hostname rewrite on rename.

Semantics verified against the runner: IOL boots from NVRAM whenever it
holds a config, so a plain stop/start never re-applies the startup
config and 'write memory' survives restarts; an explicit content edit
(PUT) is re-applied on the next start and wins over the saved config,
like IOU.
2026-09-05 21:54:52 +08:00
YueGuobin
a8fa529252
feat: add tag-keyed aggregate replay over paused markers' pcaps
Markers on different links sharing a tag form one distributed capture
session. Once every marker under the tag is paused (409 otherwise), three
read-only endpoints replay it:

- GET .../markers/tags/{tag}/replay/range merges the per-marker pcaps by
  scanning 16-byte record headers only (no tshark) into a timestamp-
  ordered frame list (per-second buckets above a 5000-frame cap)
- GET .../replay/frames?ts=&window_ms= returns frames in [T, T+window];
  an empty window is a normal empty array
- GET .../replay/frame/detail lazily decodes one frame the user opened:
  raw bytes for the hex view read straight from the pcap, protocol tree
  from 'tshark -T pdml' mapped isomorphically to JSON (every attribute
  survives, values stay strings). tshark reads a /tmp scratch copy with a
  scratch HOME — hardened profiles deny it the project directory.

Sort key is (ts, source file, frame number): ts is not unique across a
merge. The ts parameter round-trips as the exact string from the frame
list. Round-trip tests pin the PDML→JSON fidelity (element count and
attribute coverage).
2026-09-05 21:54:52 +08:00
YueGuobin
5c285f631b
fix: normalize marker.match event tag to int to match the REST schema
The WS event carried the tag as str (verbatim from the MARK signal) when
present and as int when falling back to the registry, so one tag reached
consumers as two different values. Parse the signal's decimal (the exact
value we installed via 'mark <bpf> tag <id>') and keep the registered int
on malformed input; None only when neither side carries one.
2026-09-05 21:54:52 +08:00
YueGuobin
51b3c81859
fix: make the patched get_default_project_directory order-safe in tests
A product module first-imported while run_around_tests' autouse
monkeypatch of gns3server.utils.path.get_default_project_directory is
active (e.g. 'from gns3server.api.server import app' inside a test body)
binds the patched lambda into its own namespace forever: module-level
from-imports capture the object by value. Once the patch is reverted,
that module keeps calling the stale lambda, which closed over the very
test's tmppath — a directory deleted at that test's teardown. Every
later test hitting psutil.disk_usage(get_default_project_directory())
then fails with FileNotFoundError, but only when the importing test runs
before the API test files (full-suite collection order hides it).

The replacement now resolves Config.instance().settings at call time
instead of closing over the tmppath, so a frozen reference stays
correct; the test that triggered this imports gns3_app at module top
so no product import ever happens inside a patched window.

Also pins pytest-random-order (inert without --random-order) and
documents the order-independence rules for new tests in the
gns3-api-test-writing skill, including the known-red legacy files
whose tests share rows sequentially.
2026-09-05 21:54:52 +08:00
YueGuobin
0119373690
feat: allocate IOL Docker application IDs from a pool disjoint from IOU
IOL interface MACs derive from the application ID (aabb.cc{app}{iface}),
so ids must be unique across opened projects sharing computes — the same
reason IOU has its allocator. IOL Docker nodes draw from the upper half
(512-1022, netiomux's fixed peer is 1023) so the two node types can
neither collide nor starve each other; IOU behavior is unchanged.

The controller sniffs the same GNS3_IOL_RUNNER environment marker the
compute uses to select IOLDockerVM (both the nested-properties and
template/top-level-kwarg shapes), stores the id in node properties like
IOU does, and passes it through the Docker create payload. Without an
allocation the compute falls back to a stable node-derived id in the
same upper range.
2026-09-05 21:54:52 +08:00
YueGuobin
b17d021bf6
fix: derive per-node IOL app id so linked routers get distinct MACs
IOL derives interface MACs from its application ID. With a constant
local-app every node shared the same MACs, and linked routers dropped
each other's frames as MAC loops — ARP never resolved and pings were
100% lost even though the whole uBridge datapath was forwarding.

Derive local-app from the node UUID (stable across restarts, kept in
1..1022) and align remote-app with the netiomux convention CML uses
(1023). Verified end to end: two iol-xe nodes, Ethernet0/0 link,
ARP resolves both ways, ping 4/5 (first loss = ARP), graceful
stop/start keeps the NVRAM config.
2026-09-05 21:54:51 +08:00
YueGuobin
0ca9ccc637
feat: model IOL adapters as 4-port units like the IOU node type
IOL interfaces come in 4-port units (Ethernet0/0-3, Ethernet1/0-3, ...),
addressed like IOU as (adapter_number, port_number 0-3):

- IOLDockerVM builds EthernetAdapter(interfaces=4) per adapter and asks
  the runner for adapters x 4 interfaces (num-eth).
- DockerVM threads port_number through the NIO/capture API (the compute
  routes parsed it from the URL but dropped it); single-port adapters
  keep the historical bridge{N} names and command sequence, multi-port
  adapters get one bridge per port (bridge{a}_{p}).
- The unix-socket NIO wiring addresses sockets flat across adapters:
  adapter x ports-per-adapter + port, so single-port images keep their
  exact socket layout.
- The controller port list for GNS3_IOL_RUNNER docker nodes is generated
  by StandardPortFactory with the IOU naming (Ethernet{segment0}/{port0},
  segment size 4); plain docker nodes keep eth{N}.
2026-09-05 21:54:51 +08:00
YueGuobin
9b55164064
revert IOL port-name override; document custom_adapters instead
_get_container_ifname only names the kernel interface inside the
container's network namespace (single call site: docker move_to_ns) -
IOL never uses that path, and the controller port list never reads it.
Name the ports through the template's custom_adapters instead (the
mechanism SR Linux appliances use for mgmt0/e1-1): adapter N shows as
Ethernet{N/4}/{N%4}, matching the IOS CLI, with no server-side changes.
2026-09-05 21:54:12 +08:00
YueGuobin
4c818c6b37
feat: show IOL-style interface names on iol-runner ports
Name ports after the IOL interface they map to (Ethernet0/0, one 4-port
unit per adapter range) via the _get_container_ifname override point, so
the GNS3 UI matches the IOS CLI. Display only — the flat adapter number
remains the socket index.
2026-09-05 21:54:12 +08:00
YueGuobin
d04a99f934
refactor: wire unix-socket NIOs through a per-node runtime directory
uBridge cannot reach sockets bind-mounted from the node's projects
directory: the path exceeds AF_UNIX's 107-byte sun_path cap. The /proc
detour from the previous commit is a dead end too - the runner drops
from root to the server uid, which makes the process non-dumpable and
its /proc/<pid>/root unreadable for the unprivileged server.

Instead, mirror how CML itself runs the image (source=<scratch>/tmp,
target=/tmp in its node definition): bind a per-node directory from the
runtime directory (<XDG_RUNTIME_DIR>/gns3/unixio/<node-id>, next to the
uBridge control sockets) at the image's socket directory. The path is
short, the directory is owned by the server user (whom the agent drops
its privileges to), and it is removed with the node. A socket directory
covered by a persisted volume keeps working as before.

IOLDockerVM persists only /tmp/run (nested bind at /tmp/run) and cleans
stale sockets/netio dirs from the socket directory on start, skipping
the cleanup when the container is already running. A failed wiring now
stops uBridge so the next start does not hit 'bridge already exist'.
2026-09-05 21:54:12 +08:00
YueGuobin
165f271bb8
refactor: reach unix-socket NIOs through the container root in /proc
The unix-socket NIO no longer requires the socket directory to be a
persisted volume: uBridge references the sockets through
/proc/<container-pid>/root<dir>/..., which is always well under the
107-byte sun_path cap (a project directory path alone exceeds it) and
leaves the sockets ephemeral in the container's own filesystem.

This replaces the runtime-dir symlink alias and its cleanup, and the
mount-time volume enforcement. IOLDockerVM now persists only /tmp/run
(the IOL working directory: startup-config + NVRAM) as a nested bind
instead of the whole /tmp; stale socket cleanup is gone with it, as
containers are recreated on every start.
2026-09-05 21:54:12 +08:00
YueGuobin
42e26717e6
fix: alias long unix-socket NIO paths through the runtime dir
AF_UNIX sun_path caps at 107 bytes and node volume directories
(projects/<uuid>/project-files/docker/<uuid>/tmp) exceed it — uBridge
rejects the NIO with 'invalid file path size'. When the wiring path is
too long, create <XDG_RUNTIME_DIR>/gns3/unixio-<node-id> as a symlink
to the real volume directory (same trick as the uBridge control
socket) and reference the alias in the uBridge commands; the symlink is
removed when uBridge stops. Found during E2E with iol-xe:17-18-02.
2026-09-05 21:54:12 +08:00
YueGuobin
e272ad915b
feat: add IOL (iol-runner) Docker node support
Run Cisco CML containerized IOL images (e.g. iol-xe/iol-xe:17-18-02,
driven by virl.lab/cmd/iol-runner) as GNS3 Docker router nodes:

- VendorDockerVM: generic GNS3_UNIX_SOCKET_NIO/GNS3_UNIX_SOCKET_DIR knobs
  wiring adapters through AF_UNIX datagram socket pairs
  (add_nio_unix cNN.sock sNN.sock) instead of TAP + move_to_ns; node
  creation fails if the socket dir is not a persisted volume.
- IOLDockerVM (GNS3_IOL_RUNNER=1): forces skip-init + unix-socket NIO +
  /config,/tmp volumes, writes iol-config.json on every start (num-eth
  tracks adapters, runner drops to the server uid/gid so the sockets are
  reachable), pre-creates tmp/run, cleans stale sockets after unclean
  kills, and makes reload a graceful stop + full start (NVRAM flush +
  rewiring). Console stays telnet on PID 1 stdio.
- Manager selects the node class from console_type or GNS3_* environment
  markers (create-time, like console_type).
- Image-free tests (25) and feature documentation.
2026-09-05 21:54:12 +08:00
Cristi
5d08cc92a4 fix: Fix failing tests and adress the MCP 2.x incompatibility failures 2026-09-04 17:37:36 +03:00
Cristi
28f0d9c009 feat: Add Docker link carrier and interface status support 2026-09-04 09:45:41 +03:00
Jeremy Grossmann
d1b4de6b8f
Merge pull request #2868 from yueguobin/feat/template-delete-usage-guard
feat: forbid deleting templates and images still used by projects
2026-08-31 19:03:28 +02:00
YueGuobin
823cf37287
fix: list every project in template/image in-use refusal, not just the first
The usage scans broke out of the whole (project, node) iteration at the
first matching node, so deleting a template or image used by several
projects reported only one project name while the error message says
'one or more projects'. Keep scanning and list each project once.

Caught on a live server: an IOU L3 template used by two projects
(one of them closed) was refused citing only one of them.
2026-08-31 23:04:00 +08:00
YueGuobin
eed981fee3
fix: tear down controller console WebSocket forwarding cleanly on client disconnect
Closing a web console while the node kept streaming output crashed the
ws_console/vnc_console handlers with an uncaught WebSocketDisconnect from
the compute-to-client send path (only the opposite direction was guarded),
producing a full ASGI traceback on every console close.

Restructure both endpoints as symmetric forwarding tasks managed with
asyncio.wait(FIRST_COMPLETED): exceptions from either direction are
collected as task exceptions, the peer task is cancelled, the compute
WebSocket is closed, and the client is notified. The receive loops now
close and log on every exit path instead of only in the exception branch.

Tests patch the in-memory ASGI transport to deliver a conformant
websocket.disconnect on client close (it sends a non-conformant
websocket.close that starlette receive() rejects).
2026-08-31 22:43:41 +08:00
YueGuobin
13548deae8
feat: forbid deleting templates and images still used by projects
Deleting a template with prune_images, deleting an image, or pruning
orphan images only checked template references — a project node still
pointing at the image (e.g. via hda_disk_image_backing_file) was left
with a dangling reference and the project could no longer be opened.

- Add controller helpers scanning every known project (opened projects
  via in-memory nodes, closed projects via their .gns3 file) for
  template and image usage; unreadable topologies are skipped
- Guard DELETE /templates/{id}, DELETE /templates/{id}?prune_images,
  DELETE /images/{path} and /images/prune with a 409 listing the
  project names
- Run all template-delete checks before any mutation so a refused
  deletion cannot leave the template gone while its images survive
2026-08-31 22:30:42 +08:00
YueGuobin
3cf9dbff5b
fix: handle client disconnect in compute console WebSocket forwarding
The compute-side telnet_forward (and vnc_forward) let the
WebSocketDisconnect that starlette raises from send_bytes once the peer
is gone escape to the generic task-exception handler. Its str() is
empty, so every client disconnect during active node output logged a
message-less warning:

  WARNING gns3server.compute.base_node:658 Exception while forwarding
  WebSocket data to TELNET server:

Catch WebSocketDisconnect in both forwarders and log it at INFO as the
normal end of a session, and format the remaining task exceptions with
!r so their type stays visible. Mirrors the controller-side fix for
the same scenario (2324dcd74).
2026-08-29 10:09:05 +08:00
YueGuobin
d1edfbe5e8
fix: replace Bearer JWTs with path-bound access tickets in MCP download tools
link_capture_download and get_symbol embedded a 10-min JWT in the
Authorization header of the curl command they return; LLM clients
retyping that command corrupted the long token — the same failure
class as the console WebSocket URLs fixed in the previous commit.

Generalize the ticket store (console_tickets.py -> access_tickets.py,
ConsoleTicketService -> AccessTicketService): a ticket now binds to
either a node's console endpoints (WebSocket, matched against route
path params) or one exact REST resource path (capture file, symbol
image). The two binding modes are isolated — a node-bound ticket
cannot authenticate a REST resource and vice versa.

get_user_from_token redeems path-bound tickets through the existing
token parameter / Bearer header, matched exactly against
request.url.path, then reuses the shared user lookup and token_version
revocation checks. Download URLs embed ?token=<ticket> and the curl
commands no longer carry a Bearer header.
2026-08-29 01:10:19 +08:00
YueGuobin
6d6b5351fa
fix: issue short-lived console tickets instead of JWTs in node_console MCP tool
LLM clients transcribing the console WebSocket URL into shell commands
reliably corrupted the ~200-char JWT embedded in it (dropped header
segment -> "MissingAlgorithmError: Missing 'alg' value in header" on
every connection attempt). The node_console tool now mints a short
random ticket ("gns3t_" + 16 urlsafe chars, 10 min TTL, multi-use)
stored server-side and bound to the node's console endpoints:

- new ConsoleTicketService (gns3server/services/console_tickets.py),
  in-memory store with lazy expiry sweeps
- get_current_active_user_from_websocket redeems tickets through the
  existing "token" query parameter, gated on websocket.path_params so a
  ticket only authenticates the console/ws and console/vnc routes of
  the node it was minted for; the JWT path is unchanged
- redemption reuses the existing user lookup, token_version revocation
  and is_active checks, so logging out invalidates outstanding tickets
- vnc_url no longer embeds the full session JWT
- the tool docstring now tells clients to run the returned command
  verbatim instead of reconstructing the URL
2026-08-29 01:00:12 +08:00
YueGuobin
2324dcd744
fix: handle client disconnect in console WebSocket forwarding
The compute-to-client forwarding loops in ws_console and vnc_console had
no WebSocketDisconnect handling: when a client (WebUI, or an MCP-driven
websocat session killed by timeout) disconnected while the compute was
still streaming console output, the next send raised WebSocketDisconnect
that leaked all the way up to uvicorn as an ERROR-level ASGI traceback.

Catch it and log at info level, symmetric with the receive-side handlers.
2026-08-28 23:35:33 +08:00
YueGuobin
df25e037ea
fix: accept the 'local' compute id in compute tools
The compute_get/compute_images MCP tools typed compute_id as a UUID, so
passing 'local' (the actual id of the built-in compute, which the
compute_images description itself pointed to) was rejected by schema
validation. Both tools now take a string defaulting to 'local', and the
compute_get REST route resolves 'local' through the controller since the
local compute has no database entry.
2026-08-26 00:44:57 +08:00
YueGuobin
6703e50487
fix: unify the device tool error contract
The device tools reported failures in three shapes: topology-level
entries with only an error key, per-device entries with status 'error'
plus the reason under output (VPCS tool only), and raw exceptions
leaking out of template rendering. Every in-band error entry now
carries status 'failed' and an error message, and invalid Jinja2
templates are reported in-band instead of escaping the handler.
2026-08-26 00:39:38 +08:00
YueGuobin
2951af6eab
fix: reject non-VPCS nodes in the VPCS config tool
VPCS syntax typed into another node's console is silently discarded
(IOS answers % Invalid input) while the tool still reports success.
get_device_ports_from_topology now carries the GNS3 node type through
to callers, and VPCSCommands fails device preparation with a per-device
error unless the node type is vpcs.
2026-08-26 00:36:47 +08:00
YueGuobin
8a8314ab29
fix: dedupe and report automatic template creation from images
install_appliances_from_image relied on the name+version pair check in
TemplatesService, so the same appliance reached through a second image
(the CSR1000v case) created a second template sharing the name. The auto
path now skips when any template with the same name exists, whatever the
version, and returns a manifest of created and skipped candidates;
POST /images/install replies 200 with that manifest instead of an empty
204, and the image_install MCP tool surfaces it.
2026-08-26 00:04:57 +08:00
YueGuobin
888afdccbd
fix: return the created template from appliance install
POST /appliances/{id}/install replied 204 with an empty body, so the MCP
appliance_install tool crashed with 'Expecting value: line 1 column 1'
while the template had actually been created. The route now returns the
created template (201, response_model=schemas.Template), _create_template
propagates it, and the MCP handler parses the body defensively so an
empty reply degrades to a plain success message.
2026-08-25 23:39:38 +08:00
YueGuobin
73e5e27c7b
fix: keep default node naming aligned with batch submission order
The controller assigns default names (R-1, R-2, ...) and console ports
in request arrival order. A parallel batch fan-out lets thread scheduling
decide that order, so the first submitted node could end up as R-2.
Batches that rely on default naming (any node without a name) are now
created sequentially; batches with explicit names stay parallel. The
node_create tool description documents the ordering semantics and tells
callers to correlate nodes by node_id.
2026-08-25 22:22:08 +08:00
YueGuobin
636abde16c
fix: keep node file content byte-faithful in node_file_get
Splitting the file with keepends=False and rejoining with newlines
dropped the trailing newline of the last line (and every \r of CRLF
files), so returned content was shorter than the file on disk and did
not round-trip. Split with keepends=True and join the selected lines
verbatim; pagination semantics are unchanged.
2026-08-25 21:33:21 +08:00
YueGuobin
57b5baed7f
fix: keep submission order and unify status in MCP batch handlers
Batch node/link creation collected results with as_completed, so the
response order followed completion rather than the submitted array and
callers could not correlate entries. Collect in submission order via
pool.map, and report batch deletes as status=success like every other
batch action (the message still says what was deleted).
2026-08-25 21:32:09 +08:00
YueGuobin
c9bc635996
fix: propagate 405 when suspending a node without suspend support
Suspending a single VPCS/IOU node returned a fake 204: the controller
route swallowed the compute 405 that the node types honestly raise, so
callers saw success while the node stayed started. Surface the 405
instead. The best-effort swallow on suspend_all is kept (and now covered
by a test) since mixed projects legitimately contain always-running node
types.
2026-08-25 21:29:53 +08:00
YueGuobin
97e7a79117
fix: report empty projects as not locked
GET /projects/{id}/locked returned True for a project with no drawings
or nodes: both loops ran zero times and the fallback return won. Locking
and unlocking such a project always succeeded while the state stayed
"locked", so it could never be unlocked.

Report a project with nothing to lock as not locked, and re-check the
state after unlock in the route tests.
2026-08-25 13:45:36 +08:00
YueGuobin
f31bfffefc
feat: expose data_link_type on the link_marker MCP tool (create-only)
Per-link markers on serial links need the WAN encapsulation (e.g.
DLT_C_HDLC) so the BPF compiles against the right link layer — the
REST API already accepts it, but the MCP tool never forwarded it.
Create passes it through; update ignores it (changing it would
invalidate the capture file), matching the REST schema semantics.
2026-08-25 13:29:39 +08:00
YueGuobin
702fc1f6d9
fix: never allow the projects directory to become a project directory
Loading a .gns3 placed directly in the projects root registered the
shared projects directory as the project path (load_project derives
the path from the file's parent directory). Deleting such an entry ran
rmtree on the projects directory itself, wiping every project until a
root-owned file stopped it, and left a zombie entry in the controller.

Three layers of protection:

- Controller.load_project() refuses a .gns3 whose parent directory is
  the projects directory; the normal subdirectory layout is unaffected
- the Project.path setter rejects the projects directory itself and
  its ancestors, closing the same hole for POST/PUT with an explicit
  path
- Project.delete() uses realpath + commonpath instead of commonprefix:
  entries whose path is the projects root are refused, and sibling
  directories sharing a string prefix (/srv/projects-evil vs
  /srv/projects) are no longer treated as inside the projects dir

Also removes the project_load MCP tool: loading by raw server
filesystem path is a footgun for automated clients; projects can still
be opened by project_id via the remaining tools.
2026-08-25 13:17:37 +08:00