The SKIP_INIT volume bridge replicated init.sh's seed + mount --bind script
via docker exec *after* the container started. 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. The exec runs
concurrently with the NOS boot, so whether the NOS loaded its persisted
config or the overlay's factory copy was a timing race:
- single node stop/start on an idle system won it (exec ~1s, SR Linux reads
its startup config at ~2-4s) — the save/stop/start round-trip passed;
- a server restart + project reload lost it (concurrent node starts queue on
the Docker API, delaying the exec by seconds) — SR Linux booted factory
while the persisted config.json sat intact on the host;
- XRd was immune (systemd boots tens of seconds before XR touches
/xr-storage), which is why the race was never observed on it.
Replace the bridge entirely:
- new DockerVM._prepare_volumes hook (no-op in the base class) runs in
create() after the image is present, before the container is created;
VendorDockerVM overrides it to seed each volume's host directory from the
image (throwaway docker create container + docker cp -a, nothing
executes). The .gns3_perms marker gates the seeding: a volume that ever
started is never re-seeded, so saved configuration is never overwritten
with factory content (also the upgrade path for existing nodes).
- VendorDockerVM._mount_binds now binds the volumes directly at their real
in-container paths (/etc/opt/srlinux) instead of /gns3volumes aliases, so
the persisted config is visible to the NOS from the very first process.
- _setup_skip_init_volumes and its start() call are gone; the container-side
_fix_permissions targets the volume paths directly (the direct binds
exist for the whole container lifetime, unlike the old bridge).
The volume-list computation (validation + overlap de-duplication) moves
into DockerVM._persistent_volume_list so create-time seeding and _mount_binds
cannot drift apart.
SkillsLoader.load_device_skills() now supports two device layouts: the
existing single file and a split directory (device/<device>/_base.yaml +
one YAML per protocol topic). Topic files are merged into the device
skill under 'topics', keyed by their 'topic' field; mismatched
device_type, missing _base.yaml and duplicate topics are handled with
explicit log-and-skip.
get_skill() and DeviceSkillsTool gain a 'topic' parameter following the
injection list -> index -> issue pattern. Topic bodies are never
returned without an explicit topic request - index/summary/full all
serve a topic index instead - so growing a device with new protocol
topics no longer grows the token cost of device-level lookups.
Also fix reload_skills() to actually drop injection skills that fail
validate_skill_format() instead of logging 'skipping' and merging them
anyway.
A browser's terminal-size control frame (NAWS through the console telnet
server) can arrive while client_connected_hook is still creating the
exec; the resize is a no-op then, and the tall default applied after
creation would overwrite it, leaving the session at 511x10000 until the
user resizes.
Record sizes received before the exec exists and prefer them over the
tall default once creation finishes. The recorded size is cleared when
the last client disconnects, together with the restore-to-default.
The exec behind a docker_exec console is shared by every console client,
so a browser's terminal-size resize (WS control frames -> NAWS) also
changes the geometry concurrent netmiko sessions see. SR Linux doesn't
care (no pager, no hard wrapping), but CLIs that page on the PTY window
size (IOS-XR) would park at --More-- again the moment a browser is
connected.
Split the client-driven NAWS path (_on_naws) from the internal resize
(_resize_exec): GNS3_CONSOLE_RESIZE=0 makes the console ignore client
resizes entirely and keep the tall 511x10000 no-paging default, while
the creation-time default and the restore-on-last-disconnect still go
through the internal path. XRd appliance templates should set it.
The docker_exec console defaults its exec PTY to 511x10000 (the no-NAWS
default that keeps the IOS-XR pager quiet for netmiko). A CPR-answering
client (xterm.js) on top of that tall canvas makes prompt_toolkit-based
CLIs (SR Linux sr_cli) re-emit their accumulated output on every
incremental render: ~145 KB instead of ~60 KB per command, visible in
the WebUI as full-screen clear/redraw flicker.
Let WebSocket console clients propagate their real terminal geometry:
binary frames {"cols": N, "rows": M} alongside text frames carrying
terminal data. The controller forwards binary frames (previously only
text was forwarded), and the compute side turns them into a NAWS
subnegotiation for telnet-based consoles (docker_exec included) or an
asyncssh pty size change for SSH consoles.
The docker_exec console restores the tall 511x10000 default when its
last client disconnects, so a later non-NAWS client (netmiko, bare
telnet) connecting to the still-live exec doesn't inherit a browser
geometry and hit PTY-window paging again.
The brctl Ethernet switch runs a per-port uBridge relay, so it can host
the mark filter and packet filters like any uBridge-backed node. Add
ethernet_switch to _MARKER_CAPABLE_TYPES and _get_filter_node, narrow the
UDPLink.update() NIO-PUT skip down to the Dynamips-hosted ethernet_hub,
and expose the matching compute endpoints: PUT nio (filter/marker
reapply) plus the per-marker toggle/pause/resume/delete/rebuild routes.
The ethernet_hub keeps its exclusion: its routes still wire into the
Dynamips hub, which has no uBridge of its own.
Web clients serialize empty form fields as "" while unset values are
stored as None on the node. The bare != diff in the update handler then
sees a phantom change on every full PUT and recreates the container for
nothing -- even when the user only changed a controller-only field such
as netmiko_device_type.
Normalize at the schema boundary ("" -> None for start_command,
environment and extra_hosts; "" -> "/" for console_http_path), make
the setters apply the same canonicalization, and create nodes through
the setters instead of bypassing them in __init__ so both paths store
identical values.
_resolve_token tried the JWT path before checking for the gns3_ prefix,
so every API-key connection logged a spurious "JWT rejected" ERROR from
get_token_data. Check the prefix first, and downgrade the JWT-rejected
log to WARNING — a rejected token is a client problem, not a server one.
node_console_info now returns token_sha256_prefix (sha256, first 8 hex
chars) and token_ttl_seconds alongside the console WebSocket URL, and
controller WebSocket auth rejections include the sha256 prefix of the
token as received. Comparing the two immediately distinguishes a token
corrupted in transfer from server-side rejection causes (expired,
revoked, bad signature).
get_token_data used to raise the same "Could not validate credentials"
for every JWT-level failure (bad signature, expired, malformed), which
made console WebSocket auth failures impossible to tell apart. Return a
distinct detail per cause and log the underlying exception plus the
unverified header alg value on rejection.
The docker_exec console resized its exec PTY to 80x24 until a client
sent NAWS. CLIs that page on the PTY window size instead of the
terminal length (the IOS-XR pager) therefore parked long output at
--More-- for clients that never negotiate NAWS — netmiko, bare telnet —
making copilot device commands time out on XRd.
Default the exec to 511x10000 instead (511 matches netmiko's own
'terminal width 511' convention): no paging and no hard wrapping for
non-NAWS clients, while real NAWS clients keep resizing to their actual
geometry as before.
Also updates the project memory record with the confirmed root cause
and the fix.
The vendored gns3fy Node model dropped default_username/default_password
from the API response, and the nornir groups hardcoded empty
credentials, so drivers that require authentication (gns3_ruijie_telnet,
stock netmiko SSH/telnet) could not log in.
Carry the per-node credentials through nodes_inventory() into the
nornir hosts data at host level, where they override the group's empty
fallback. Missing or cleared ("") values keep inheriting from the
group, so no-auth drivers are unaffected.
New GET /v3/netmiko/device_types endpoint returns the device types
supported by the netmiko library installed on the server, including
the gns3-copilot custom drivers, so the web UI can populate the
netmiko_device_type dropdown on templates and nodes.
The list is read at runtime from netmiko's ssh_dispatcher.CLASS_MAPPER
registry (the same table ConnectHandler dispatches on), filtered to
drop the '_ssh' aliases and the 'autodetect' pseudo type, and cached
for the process lifetime. Returns 501 when netmiko is not installed
(ai-features extra).
Add default_username/default_password as controller-only node properties
(the netmiko_device_type pattern): they are not sent to the compute,
persist with the project topology and can be updated or cleared per
node. Creating a node from a template seeds them from the template
appliance metadata, and the metadata itself is dropped there so it
never leaks into the node properties.
Appliance fields that describe the appliance (vendor information, default
credentials, installation instructions...) were dropped when installing a
template. Keep them in a new appliance_metadata JSON column on the
templates table, filled by the appliance-to-template conversion for both
registry v1-6 and v8 (version level values override the appliance level
ones). The nested schema allows extra fields so future registry fields
persist without a migration.
- install: resolve the image directory from the version's settings type and
skip image handling for docker appliances; guard appliance.images
- appliance schema: validate template_properties against template_type,
align cpu_throttling with the qemu template, add kvm and version idlepc
- conversion: map IOU image to path, kvm disable to accel=tcg, inherit only
same-type default settings, symbol fallback from the effective category,
template_properties cannot override structural fields
- allow clearing netmiko_device_type with an empty string
- download the template symbol regardless of the level it is defined at and
give qemu guests a default symbol
The vendored gns3fy Node model and its nodes_inventory() now carry the
node's netmiko_device_type field, and get_device_ports_from_topology()
resolves the Netmiko device type from it first, falling back to the
device_type:<type> tag. Nodes created from a template inherit the value
from the template automatically, so automation tooling gets the correct
Netmiko driver without tags.
Both the v1-6 and v8 appliance models accept an optional top-level
netmiko_device_type, and ApplianceToTemplate copies it into the created
template so installed appliances carry the automation hint end to end.
netmiko_device_type follows the CONTROLLER_ONLY_PROPERTIES pattern
(like console_auto_start): a node created from a template inherits the
template value, PUT /nodes can override it inside a topology, updates
never round-trip to the compute, and the value persists in the project
topology file.
Common template field (schema + templates table column + Alembic
migration) holding the Netmiko device type (e.g. 'cisco_xr', 'nokia_srl')
so Netmiko/Nornir based tooling can look up how to reach a node's CLI
without hard-coded vendor mappings. Free-form lowercase string on
purpose: Netmiko's platform list evolves independently of GNS3.
DockerPropertiesV8 now accepts custom_adapters (already available to
v1-6 top-level appliances and to Qemu v8 properties), so port-named
Docker appliances (XRd, SR Linux) can move to the v8 format without
losing their interface naming.
Appliance.type resolves the node type from the v8 settings template_type
(default set first) instead of misclassifying every v8 appliance as
qemu, and _get_default_symbol applies the docker guest symbol to v8
Docker guest appliances.
new_template() now converts the v8 settings[] format per the spec in
gns3-registry#734: settings selection (version name reference, then the
default set, then a single set), inherit_default_properties merging, and
template_properties expansion with category/usage/symbol resolved from
template_properties > version > appliance levels. Undefined properties
are left out so controller template defaults apply.
Registry versions 1-6 keep the existing top-level emulator block path.
The vendored gns3fy copy keeps its type lists as literals (the module is
shared with the standalone MCP service and cannot import server enums),
and CONSOLE_TYPES had drifted: 'ssh' and 'docker_exec' were missing while
both are valid server-side. Impact: the copilot topology reader validates
the whole node list in one pydantic pass, so a single vendor NOS node
(console_type 'docker_exec') made it drop the entire project and return
zero devices to every copilot device tool.
Add the missing values plus drift tests asserting the vendored lists
cover the server enums (skipped when ai-features extras are absent).
The 600 s clamp was unreachable in practice: the controller's stop
request times out at 240 s (controller/node.py) and the Docker stop
query gets the value +30 s as its HTTP timeout, so anything above 210
would abort upstream first and surface an error while the stop keeps
running server-side. Cap at the derived ceiling and document the chain
in the clamp and the docstring.
Nine fixes from a review of the docker-shm-devices diff:
* GNS3_STOP_TIMEOUT >300 s aborted at the manager's default HTTP timeout
before Docker finished the stop — the stop query now gets a timeout
with a margin over the grace period.
* Overlapping bind targets (GNS3_MASK_UDEV + GNS3_MASK_SYSTEMD on the
same unit, a unit named twice, an extra_configs target equal to a
masked unit) made Docker reject the create with 'Duplicate mount
point' — Mounts are deduplicated by target.
* ExtraConfig.target now carries a pydantic validator (absolute file
path, no '..'), so bad targets 422 at template-save time instead of
failing at node-create time after a multi-GB image pull; directory
forms ('/', '/etc/') are also rejected by the runtime guard instead
of raising IsADirectoryError (raw 500).
* _check_host_readiness skipped every remaining check when one
/proc/sys key was unreadable (mid-loop return) — now continues.
* The base-class GNS3_* env parser strips trailing commas like the
vendor parser, so 'GNS3_MASK_UDEV=1,' composed from a list still
activates.
* Vendor env knobs are re-parsed on every create(), so a PUT to the
node's environment takes effect on the next (re)create.
* The graceful SIGTERM stop is now limited to the explicit user stop
route; delete/update/close/crash-cleanup keep the immediate kill
(those paths force-delete or recreate the container right after).
* An extra_configs target beneath a persisted volume is shadowed by the
volume bind — warn at create time.
The 60 s SIGTERM grace was hardcoded, unlike every other vendor knob
(GNS3_SHM_SIZE, GNS3_DEVICES, GNS3_MASK_UDEV, ...) which rides the
environment line. Parse GNS3_STOP_TIMEOUT=<seconds> (default 60,
clamped to 1-600, invalid values keep the default) and use it in
VendorDockerVM._terminate_container().
DockerVM.stop() terminated containers with an immediate SIGKILL — fine
for init.sh-based containers whose state is persisted beforehand, but a
systemd NOS (Cisco XRd, SR Linux) needs a graceful shutdown and treats
the abrupt kill as an unclean shutdown (exit 137 on every stop).
Extract the final termination into _terminate_container() and override
it in VendorDockerVM: POST /containers/{id}/stop?t=60 sends SIGTERM and
waits for systemd to stop services; Docker itself SIGKILLs the
container once the grace period expires, so no fallback is needed.
Docker's 304 (already stopped) is swallowed.
PortManager.get_free_udp_port had an unguarded find-then-add sequence.
A link allocates both ends concurrently (asyncio.gather in
UDPLink._prepare -> two POST /ports/udp) and FastAPI runs the sync
route handler in a threadpool, so both threads could probe the same
'free' port before either recorded it — handing lport == rport to both
ends. uBridge sets SO_REUSEADDR on UDP NIO sockets, so the double bind
succeeds silently and the kernel delivers everything to the last-bound
socket: one node starves, the other echoes to itself.
Make every TCP/UDP allocate/reserve/release path atomic with an RLock,
and rebuild _link_data in UDPLink._prepare so reset() commits the fresh
port pair instead of re-sending the stale, already-released one.
Regression tests: threaded barrier allocation never returns duplicates
(red on the old code, UDP and TCP); reset() leaves exactly one mirrored
NIO pair per side with lport != rport (red on the old code).
Masking the udev systemd units stopped the daemon's coldplug (host audio
resets), but host USB devices still reconnected on every XRd start. A/B
testing with plain `docker run` isolated the trigger: XRd's own
xr_startup.sh calls udevadm directly (USB license-dongle probing, e.g.
`udevadm trigger --action=add --parent-match=<usb device>`), which
synthesizes uevents into the host kernel from the privileged container --
no udevd required.
GNS3_MASK_UDEV=1 now also binds /dev/null over the udevadm binary
(/bin, /sbin, /usr/bin). Verified with a plain-run experiment: with the
bind, host udev monitor shows zero usb/input/hid/sound events during XRd
boot (only normal docker veth traffic), and XRd itself boots to running
state -- it does not need udevadm under GNS3 (interfaces are pre-created
veths).
The vendor skip-init path (_setup_skip_init_volumes, _fix_permissions) runs
`/gns3/bin/busybox chown` inside the container via docker exec. busybox is
statically linked, and its chown dlopens NSS modules (libnss_*) from the
container; on NOS images whose glibc differs from the host's (e.g. Cisco
XRd) that mismatches and aborts with the glibc assertion
`_dl_call_libc_early_init: sym != NULL` (SIGABRT). The per-file chown loop
then crash-loops, and the resulting core-dump storm -- processed by the
host's systemd-coredump -- cascades into host device rescans, reconnecting
USB / resetting audio / corrupting the journal on every XRd start.
cp/chmod/find/stat don't touch NSS and work fine on busybox, so only chown
is affected. Prefer the container's own coreutils chown
(`command -v chown && chown ...`), falling back to busybox chown only when
the container ships no chown (minimal images, where the glibc matches and
busybox is safe).
A privileged systemd-based NOS container (Cisco XRd boots /usr/sbin/init)
runs systemd-udevd, which on startup coldplugs every device it can reach.
In privileged mode that includes the HOST's USB/input/audio/disk devices,
so every XRd start reconnects USB, mutes audio, and disrupts the host
journal -- highly disruptive on Linux desktops (caught in the act: the
container's udevd was even rescanning the host BTRFS root device).
XRd doesn't need udev (its interfaces are pre-created by GNS3 veth and
mapped via XR_INTERFACES). Add two opt-in env vars, consumed host-side at
container create time in the inherited DockerVM.create (so VendorDockerVM
nodes get it too):
GNS3_MASK_UDEV=1 -> bind /dev/null over the udevd unit, its two
activation sockets, and the coldplug/settle
trigger services
GNS3_MASK_SYSTEMD=u1,u2 -> bind /dev/null over arbitrary units in
/etc/systemd/system/ (comma/semicolon list)
Only injected when set, so ordinary nodes are unaffected.
The extra_configs schema field needs a column on the docker_templates table
to actually round-trip through the controller DB (the schema alone is
accepted but dropped by the SQLAlchemy model mapping). Add the JSON column
and an Alembic migration so existing databases get it on upgrade.
Add an `extra_configs` field (list of {target, content}) to the docker
node/template/appliance schemas. For each entry GNS3 writes `content` to a
file in the node working directory and bind-mounts it read-only at `target`
inside the container.
This lets a NOS appliance seed its startup config without rebuilding the
image: XRd points XR_FIRST_BOOT_CONFIG at an injected /firstboot.cfg, FRR at
/etc/frr/frr.conf, etc. The bind is a single-file mount applied at create
time, so it works for both the generic init.sh path and vendor nodes that
skip init.sh (console_type=docker_exec). Entries are only injected when
present, so ordinary nodes are unaffected.
The content can't go through `environment` (it is line-delimited, one var per
line), hence a dedicated field -- the same plumbing shape as extra_volumes.
Add a read-only _check_host_readiness() that runs once after the Docker
daemon connection is established. It reads /proc/sys inotify/file-max
limits and /proc/filesystems (for FUSE), and logs a warning with the exact
commands to fix when they are too low for heavy containers -- XRd wants
~4000 inotify instances per node against a stock default of 128.
The server runs unprivileged (only the setuid ubridge helper has root), so
it can only check, not set; the warning tells the admin exactly what to
raise once. Stays silent when the limits are already sufficient.
Heavy NOS containers (e.g. Cisco XRd) need /dev/shm larger than Docker's
64 MB default and host device nodes such as /dev/fuse. Add two opt-in
environment variables, consumed host-side and applied as native Docker
HostConfig keys at create time:
GNS3_SHM_SIZE (MB) -> HostConfig.ShmSize (bytes)
GNS3_DEVICES -> HostConfig.Devices in `docker run --device` syntax
(host[:container[:perm]]; Docker resolves major/minor
from the host node itself)
Native HostConfig (rather than remount/mknod inside init.sh) is used so this
works for vendor NOS nodes that skip init.sh (console_type=docker_exec) --
the path XRd must take, since GNS3's init.sh wrapper crashes XRd's glibc
loader. It applies whether or not init.sh runs, needs no schema/API/UI
change (reuses the `environment` field), and only takes effect when the vars
are set, so ordinary nodes keep default Docker behaviour.
GNS3_-prefixed user env vars stay dropped from the container environment
(only consumed here host-side), keeping GNS3-injected vars safe.
The top-level ApplianceV1_6 model declared first_port_name /
port_name_format / port_segment_size but not custom_adapters, so the
GET /appliances endpoint (response_model=schemas.Appliance) stripped
custom_adapters from the response — the frontend never saw per-adapter
port names even though the appliance file and the server-side template
conversion (appliance_to_template reads it from the raw dict) handled it.
Add custom_adapters: Optional[List[CustomAdapterItem]] to ApplianceV1_6
so the field round-trips through the API. (ApplianceV8 already models it
inside its TemplateSetting.)
The Docker appliance Pydantic model (DockerConsoleType) rejected
console_type='docker_exec', so an appliance file using the vendor NOS
docker_exec console could not be loaded (validation error at import).
Add docker_exec to the enum — it is already a valid ConsoleType
(schemas/common.py) and is handled by VendorDockerVM.
The reconnect-blank-screen bug: when sr_cli exited (quit / idle timeout /
crash) the while-true wrapper restarted it mid-session with no client
attached, so its startup CPR probe (\e[6n) went unanswered and the TUI
degraded/blocked. On reconnect lazy_started=True skipped recreation, so the
client saw a blank screen.
Fix: drop the while-true wrapper. Now when the CLI exits, the exec pty
closes (EOF), the broadcast task ends, and the next client connection
detects the dead upstream via _upstream_alive() and recreates the exec —
with a terminal attached, so CPR is answered. A live exec is reused
(just a Ctrl-L redraw).
_LazyExecTelnetServer is extracted from a closure to module level so the
reconnect/recreate logic is unit-testable. Add 9 tests covering
_upstream_alive states and the recreate-on-death / reuse-if-live /
close-half-dead-writer / no-while-true behaviors.
Full Docker suite (120) passes.
Override _mount_binds in VendorDockerVM: for GNS3_SKIP_INIT containers the
/etc/network volume (GNS3's own network config consumed by init.sh's ifup)
is dead weight — init.sh never runs and the NOS manages its own
interfaces. The override removes the bind, filters /etc/network out of
self._volumes (keeping GNS3_VOLUMES and the bridge/fix passes consistent)
and deletes the host-side skeleton directory created by the base class.
Without GNS3_SKIP_INIT the mount is kept, matching base behaviour.
_persistent_volumes() is removed — the mount override is now the single
filter point.
GNS3_SKIP_INIT containers never run init.sh, so /etc/network (GNS3's own
network config consumed by init.sh's ifup) has no consumer — the NOS
manages its own interfaces. VendorDockerVM._persistent_volumes() filters it
out for both _setup_skip_init_volumes and _fix_permissions, saving one
docker exec per pass. The shared _mount_binds is untouched, and without
GNS3_SKIP_INIT the full volume list is returned so behaviour matches the
base class.
The host-side pass could not work for unprivileged GNS3 processes: the
.gns3_perms marker is created root-owned by the container-side touch, and
chowning root-owned files from the host requires root.
Rewrite VendorDockerVM._fix_permissions to run the busybox
record/chmod/chown script inside the container (as root) on the
/gns3volumes bind-mount targets — they exist for the container's whole
lifetime and do not depend on the mount --bind bridge, so a container
restart can no longer make the fix hit the overlay copy. A
stopped/exited container is skipped (logged) instead of restarted; the
next start's pass fixes ownership.