From 3d3e20f69e8f967d884fe4b6e925a14cdb08883c Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 31 Jul 2026 22:27:16 +0800 Subject: [PATCH 01/36] Add configurable uBridge control channel transport (tcp/unix) Add ubridge_control_transport to [Server] config (default "tcp", fully backward compatible). Selecting "unix" switches the uBridge hypervisor control channel from the unauthenticated TCP listener (-H) to an AF_UNIX socket (-U) authenticated in-kernel via SO_PEERCRED. - UBridgeHypervisor: supports both socket_path (AF_UNIX) and host/port (TCP); a new `endpoint` property unifies log/error strings for both - Hypervisor: unix mode allocates a short socket path under a 0700 private runtime dir and unlinks it on stop; tcp mode restores the original getaddrinfo ephemeral-port allocation - base_node: reads the transport from config and passes host through - schemas/config.py + config_samples/gns3_server.conf: new option Default deployments are unchanged. "unix" requires a ubridge build that understands -U. --- gns3server/compute/base_node.py | 11 +- gns3server/compute/ubridge/hypervisor.py | 54 +++++++-- .../compute/ubridge/ubridge_hypervisor.py | 103 ++++++++---------- gns3server/config_samples/gns3_server.conf | 5 + gns3server/schemas/config.py | 14 +++ 5 files changed, 111 insertions(+), 76 deletions(-) diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index df92de0aa..a97b17de3 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -926,13 +926,16 @@ class BaseNode: raise NodeError("uBridge requires root access or the capability to interact with network adapters") server_host = self._manager.config.settings.Server.host + transport = self._manager.config.settings.Server.ubridge_control_transport if not self.ubridge: - self._ubridge_hypervisor = Hypervisor(self._project, self.ubridge_path, self.working_dir, server_host) - log.info(f"Starting new uBridge hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port}") + self._ubridge_hypervisor = Hypervisor( + self._project, self.ubridge_path, self.working_dir, transport, server_host + ) + log.info(f"Starting new uBridge hypervisor at {self._ubridge_hypervisor.endpoint}") await self._ubridge_hypervisor.start() if self._ubridge_hypervisor: log.info( - f"Hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port} has successfully started" + f"Hypervisor at {self._ubridge_hypervisor.endpoint} has successfully started" ) await self._ubridge_hypervisor.connect() # Tell this uBridge where to send MARK signals and which node id to @@ -981,7 +984,7 @@ class BaseNode: """ if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): - log.info(f"Stopping uBridge hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port}") + log.info(f"Stopping uBridge hypervisor at {self._ubridge_hypervisor.endpoint}") await self._ubridge_hypervisor.stop() self._ubridge_hypervisor = None diff --git a/gns3server/compute/ubridge/hypervisor.py b/gns3server/compute/ubridge/hypervisor.py index a702adb34..bc38b3a47 100644 --- a/gns3server/compute/ubridge/hypervisor.py +++ b/gns3server/compute/ubridge/hypervisor.py @@ -20,9 +20,10 @@ Represents a uBridge hypervisor and starts/stops the associated uBridge process. import sys import os +import socket import subprocess import asyncio -import socket +import tempfile import re from gns3server.utils import parse_version @@ -44,17 +45,36 @@ class Hypervisor(UBridgeHypervisor): :param project: Project instance :param path: path to uBridge executable :param working_dir: working directory - :param host: host/address for this hypervisor - :param port: port for this hypervisor + :param transport: control channel transport — "unix" (-U) or "tcp" (-H) + :param host: host/address for the TCP transport (unused for "unix") """ - _instance_count = 1 + _instance_count = 0 - def __init__(self, project, path, working_dir, host, port=None): + def __init__(self, project, path, working_dir, transport, host=None): - if port is None: + self._project = project + self._path = path + self._working_dir = working_dir + + if transport == "unix": + # AF_UNIX control socket (-U). sun_path is capped at 107 bytes, so + # keep it under a private runtime dir — never under the project tree + # (per-node UUIDs would overflow it). + Hypervisor._instance_count += 1 + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir() + socket_dir = os.path.join(runtime_dir, "gns3") + try: + os.makedirs(socket_dir, mode=0o700, exist_ok=True) + os.chmod(socket_dir, 0o700) + except OSError as e: + raise UbridgeError(f"Could not create uBridge socket directory {socket_dir}: {e}") + socket_path = os.path.join(socket_dir, f"ubridge-{Hypervisor._instance_count}.sock") + super().__init__(socket_path=socket_path) + else: + # TCP control channel (-H): let the OS find an unused local port. + port = None try: - port = None info = socket.getaddrinfo(host, 0, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE) if not info: raise UbridgeError(f"getaddrinfo returns an empty list on {host}") @@ -68,11 +88,8 @@ class Hypervisor(UBridgeHypervisor): break except OSError as e: raise UbridgeError(f"Could not find free port for the uBridge hypervisor: {e}") + super().__init__(host=host, port=port) - super().__init__(host, port) - self._project = project - self._path = path - self._working_dir = working_dir self._command = [] self._process = None self._stdout_file = "" @@ -214,6 +231,16 @@ class Hypervisor(UBridgeHypervisor): os.remove(self._stdout_file) except OSError as e: log.warning(f"could not delete temporary uBridge log file: {e}") + + # ubridge unlinks its AF_UNIX control socket on a clean exit; for the + # unix transport remove it here too so a killed process leaves no stale + # socket behind. The TCP transport has no socket_path. + if self._socket_path: + try: + os.unlink(self._socket_path) + except OSError: + pass + self._process = None self._started = False @@ -250,7 +277,10 @@ class Hypervisor(UBridgeHypervisor): """ command = [self._path] - command.extend(["-H", f"{self._host}:{self._port}"]) + if self._socket_path: + command.extend(["-U", self._socket_path]) + else: + command.extend(["-H", f"{self._host}:{self._port}"]) if log.getEffectiveLevel() == logging.DEBUG: command.extend(["-d", "1"]) return command diff --git a/gns3server/compute/ubridge/ubridge_hypervisor.py b/gns3server/compute/ubridge/ubridge_hypervisor.py index a44bf3834..83f765d16 100644 --- a/gns3server/compute/ubridge/ubridge_hypervisor.py +++ b/gns3server/compute/ubridge/ubridge_hypervisor.py @@ -28,20 +28,29 @@ log = logging.getLogger(__name__) class UBridgeHypervisor: """ - Creates a new connection to uBridge hypervisor. + Creates a new connection to a uBridge hypervisor control channel. - :param host: the hostname or ip address string of the uBridge hypervisor - :param port: the tcp port integer + Two transports, selected by which argument is set: + * ``socket_path`` -> AF_UNIX (``-U``), authenticated in-kernel via + SO_PEERCRED (ubridge accepts only its own UID; the compute process that + spawned it shares that UID). Recommended on Linux. + * ``host``/``port`` -> TCP (``-H``), retained for backward compatibility. + + :param socket_path: path to the uBridge AF_UNIX control socket (None for TCP) + :param host: TCP hostname/IP (None for AF_UNIX) + :param port: TCP port :param timeout: timeout integer for how long to wait for a response to commands sent to the - hypervisor (defaults to 30 seconds) + hypervisor (defaults to 30 seconds) """ # Used to parse Ubridge response codes error_re = re.compile(r"""^2[0-9]{2}-""") success_re = re.compile(r"""^1[0-9]{2}\s{1}""") - def __init__(self, host, port, timeout=30.0): + def __init__(self, socket_path=None, host=None, port=None, timeout=30.0): + # Exactly one transport is active: socket_path (AF_UNIX) or host/port (TCP). + self._socket_path = socket_path self._host = host self._port = port self._version = "N/A" @@ -54,22 +63,23 @@ class UBridgeHypervisor: Connects to the hypervisor. """ - # connect to a local address by default - # if listening to all addresses (IPv4 or IPv6) - if self._host == "0.0.0.0": - host = "127.0.0.1" - elif self._host == "::": - host = "::1" - else: - host = self._host - begin = time.time() connection_success = False last_exception = None while time.time() - begin < timeout: await asyncio.sleep(0.1) try: - self._reader, self._writer = await asyncio.open_connection(host, self._port) + if self._socket_path: + self._reader, self._writer = await asyncio.open_unix_connection(self._socket_path) + else: + # connect to a local address by default if listening on all addresses + if self._host == "0.0.0.0": + host = "127.0.0.1" + elif self._host == "::": + host = "::1" + else: + host = self._host + self._reader, self._writer = await asyncio.open_connection(host, self._port) except OSError as e: last_exception = e continue @@ -77,9 +87,9 @@ class UBridgeHypervisor: break if not connection_success: - raise UbridgeError(f"Couldn't connect to hypervisor on {host}:{self._port} :{last_exception}") + raise UbridgeError(f"Couldn't connect to hypervisor on {self.endpoint} :{last_exception}") else: - log.info(f"Connected to uBridge hypervisor on {host}:{self._port} after {time.time() - begin:.4f} seconds") + log.info(f"Connected to uBridge hypervisor on {self.endpoint} after {time.time() - begin:.4f} seconds") try: await asyncio.sleep(0.1) @@ -122,7 +132,7 @@ class UBridgeHypervisor: await self._writer.drain() self._writer.close() except OSError as e: - log.debug(f"Stopping hypervisor {self._host}:{self._port} {e}") + log.debug(f"Stopping hypervisor {self.endpoint} {e}") self._reader = self._writer = None async def reset(self): @@ -133,44 +143,17 @@ class UBridgeHypervisor: await self.send("hypervisor reset") @property - def port(self): + def endpoint(self): """ - Returns the port used to start the hypervisor. + Returns a human-readable control endpoint: the AF_UNIX socket path when + using -U, or host:port when using -H. Used for logging and errors. - :returns: port number (integer) + :returns: endpoint (string) """ - return self._port - - @port.setter - def port(self, port): - """ - Sets the port used to start the hypervisor. - - :param port: port number (integer) - """ - - self._port = port - - @property - def host(self): - """ - Returns the host (binding) used to start the hypervisor. - - :returns: host/address (string) - """ - - return self._host - - @host.setter - def host(self, host): - """ - Sets the host (binding) used to start the hypervisor. - - :param host: host/address (string) - """ - - self._host = host + if self._socket_path: + return self._socket_path + return f"{self._host}:{self._port}" @locking async def send(self, command): @@ -205,8 +188,8 @@ class UBridgeHypervisor: await self._writer.drain() except OSError as e: raise UbridgeError( - "Lost communication with {host}:{port} when sending command '{command}': {error}, uBridge process running: {run}".format( - host=self._host, port=self._port, command=command, error=e, run=self.is_running() + "Lost communication with {endpoint} when sending command '{command}': {error}, uBridge process running: {run}".format( + endpoint=self.endpoint, command=command, error=e, run=self.is_running() ) ) @@ -232,8 +215,8 @@ class UBridgeHypervisor: if not chunk: if retries > max_retries: raise UbridgeError( - "No data returned from {host}:{port} after sending command '{command}', uBridge process running: {run}".format( - host=self._host, port=self._port, command=command, run=self.is_running() + "No data returned from {endpoint} after sending command '{command}', uBridge process running: {run}".format( + endpoint=self.endpoint, command=command, run=self.is_running() ) ) else: @@ -244,8 +227,8 @@ class UBridgeHypervisor: buf += chunk.decode("utf-8") except OSError as e: raise UbridgeError( - "Lost communication with {host}:{port} after sending command '{command}': {error}, uBridge process running: {run}".format( - host=self._host, port=self._port, command=command, error=e, run=self.is_running() + "Lost communication with {endpoint} after sending command '{command}': {error}, uBridge process running: {run}".format( + endpoint=self.endpoint, command=command, error=e, run=self.is_running() ) ) @@ -255,8 +238,8 @@ class UBridgeHypervisor: continue except IndexError: raise UbridgeError( - "Could not communicate with {host}:{port} after sending command '{command}', uBridge process running: {run}".format( - host=self._host, port=self._port, command=command, run=self.is_running() + "Could not communicate with {endpoint} after sending command '{command}', uBridge process running: {run}".format( + endpoint=self.endpoint, command=command, run=self.is_running() ) ) diff --git a/gns3server/config_samples/gns3_server.conf b/gns3server/config_samples/gns3_server.conf index a8db5004c..04ed81e85 100644 --- a/gns3server/config_samples/gns3_server.conf +++ b/gns3server/config_samples/gns3_server.conf @@ -92,6 +92,11 @@ udp_end_port_range = 30000 ; uBridge executable location, default: search in PATH ;ubridge_path = ubridge +; uBridge control channel transport: "tcp" (-H host:port, default) or "unix" +; (-U socket_path; AF_UNIX + SO_PEERCRED, recommended on Linux for kernel-level +; peer authentication). TCP now binds loopback by default. +;ubridge_control_transport = tcp + ; Marker (traffic-insight) UDP sink: one listener per compute process that ; receives uBridge MARK signals from every uBridge on this host. ; marker_listen_host defaults to 127.0.0.1 because uBridge runs locally. diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index 851f0d4e3..af920c099 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -113,6 +113,16 @@ class ServerProtocol(str, Enum): https = "https" +class UbridgeControlTransport(str, Enum): + + # TCP control channel: -H host:port. ubridge now binds loopback by default, + # so this is reachable only locally. Retained for backward compatibility. + tcp = "tcp" + # AF_UNIX control channel: -U socket_path, authenticated in-kernel via + # SO_PEERCRED (ubridge accepts only its own UID). Recommended on Linux. + unix = "unix" + + class BuiltinSymbolTheme(str, Enum): classic = "Classic" @@ -154,6 +164,10 @@ class ServerSettings(BaseModel): udp_start_port_range: int = Field(10000, gt=0, le=65535) udp_end_port_range: int = Field(30000, gt=0, le=65535) ubridge_path: str = "ubridge" + # Transport for the uBridge hypervisor control channel. "tcp" (-H) is the + # historical default; "unix" (-U, AF_UNIX + SO_PEERCRED) is recommended on + # Linux for kernel-level peer authentication. + ubridge_control_transport: UbridgeControlTransport = UbridgeControlTransport.tcp # Marker (traffic-insight) UDP sink: one listener per compute process that # receives ubridge MARK signals from every ubridge on this host. The host # defaults to loopback because ubridge runs on the same host as the compute. From 2488c42cd4c299ef1715803d5afe9e10df75ba12 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 31 Jul 2026 22:40:38 +0800 Subject: [PATCH 02/36] Detect unsupported -U flag and fail fast with a clear error start() now checks whether ubridge exited immediately after launch. An old ubridge build that does not understand -U exits with a non-zero code right away; surface the reason from ubridge.log instead of waiting for connect() to time out 10s later with a confusing "couldn't connect" error. --- gns3server/compute/ubridge/hypervisor.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/gns3server/compute/ubridge/hypervisor.py b/gns3server/compute/ubridge/hypervisor.py index bc38b3a47..711be3237 100644 --- a/gns3server/compute/ubridge/hypervisor.py +++ b/gns3server/compute/ubridge/hypervisor.py @@ -186,6 +186,17 @@ class Hypervisor(UBridgeHypervisor): ) log.info(f"ubridge started PID={self._process.pid}") + # An unsupported flag (e.g. -U on an old ubridge build) makes ubridge exit + # immediately with a non-zero code. Detect that here and surface the real + # reason from ubridge.log instead of waiting for connect() to time out with + # a confusing "couldn't connect" error. + await asyncio.sleep(0.3) + if self._process.returncode is not None: + raise UbridgeError( + f"uBridge exited immediately (code {self._process.returncode}); if " + f"ubridge_control_transport is 'unix', the installed ubridge may not " + f"support -U.\n{self.read_stdout()}" + ) # recv: Bad address is received by uBridge when a docker image stops by itself # see https://github.com/GNS3/gns3-gui/issues/2957 # monitor_process(self._process, self._termination_callback) From d729f76856736a17da07d1dffea434d2025df6e2 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 31 Jul 2026 23:48:07 +0800 Subject: [PATCH 03/36] Name AF_UNIX sockets by node id for self-describing debuggability Use the node id (UUID) instead of an incrementing counter for the unix control socket name, so each socket identifies its owning node at a glance (one ubridge per node => node_id is unique). Falls back to the counter only when no node id is supplied. A single UUID fits sun_path's 107-byte cap (~69 bytes), so no project_id is needed. --- gns3server/compute/base_node.py | 2 +- gns3server/compute/ubridge/hypervisor.py | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index a97b17de3..df6fabddc 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -929,7 +929,7 @@ class BaseNode: transport = self._manager.config.settings.Server.ubridge_control_transport if not self.ubridge: self._ubridge_hypervisor = Hypervisor( - self._project, self.ubridge_path, self.working_dir, transport, server_host + self._project, self.ubridge_path, self.working_dir, transport, server_host, self.id ) log.info(f"Starting new uBridge hypervisor at {self._ubridge_hypervisor.endpoint}") await self._ubridge_hypervisor.start() diff --git a/gns3server/compute/ubridge/hypervisor.py b/gns3server/compute/ubridge/hypervisor.py index 711be3237..ae8334662 100644 --- a/gns3server/compute/ubridge/hypervisor.py +++ b/gns3server/compute/ubridge/hypervisor.py @@ -47,21 +47,27 @@ class Hypervisor(UBridgeHypervisor): :param working_dir: working directory :param transport: control channel transport — "unix" (-U) or "tcp" (-H) :param host: host/address for the TCP transport (unused for "unix") + :param node_id: node id used to name the AF_UNIX socket (unix transport) """ _instance_count = 0 - def __init__(self, project, path, working_dir, transport, host=None): + def __init__(self, project, path, working_dir, transport, host=None, node_id=None): self._project = project self._path = path self._working_dir = working_dir if transport == "unix": - # AF_UNIX control socket (-U). sun_path is capped at 107 bytes, so - # keep it under a private runtime dir — never under the project tree - # (per-node UUIDs would overflow it). - Hypervisor._instance_count += 1 + # AF_UNIX control socket (-U). Name it after the node so the socket + # is self-describing (one ubridge per node => node_id is unique). + # sun_path is capped at 107 bytes; a single UUID fits comfortably + # (~69 bytes with this prefix), so no project_id is needed. + if node_id: + socket_name = f"ubridge-{node_id}.sock" + else: + Hypervisor._instance_count += 1 + socket_name = f"ubridge-{Hypervisor._instance_count}.sock" runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir() socket_dir = os.path.join(runtime_dir, "gns3") try: @@ -69,7 +75,7 @@ class Hypervisor(UBridgeHypervisor): os.chmod(socket_dir, 0o700) except OSError as e: raise UbridgeError(f"Could not create uBridge socket directory {socket_dir}: {e}") - socket_path = os.path.join(socket_dir, f"ubridge-{Hypervisor._instance_count}.sock") + socket_path = os.path.join(socket_dir, socket_name) super().__init__(socket_path=socket_path) else: # TCP control channel (-H): let the OS find an unused local port. From 6749b872fae404a31e407d73e5301782107feac4 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 1 Aug 2026 14:52:07 +0800 Subject: [PATCH 04/36] marker: forward dir= from ubridge and add per-marker direction filter Direction field in marker.match events ======================================= Read ubridge dir= from MARK signal datagrams and forward it as a "dir" key in the marker.match notification event. The field is additive -- older ubridge builds omit it and the parser leaves it null, so consumers fall back to undirected rendering with no version coupling. Semantics are relative to the capture node (the signal's node=): tx = capture node is sending (ingressed device-side NIO), rx = it is receiving (ingressed link-side NIO). Per-marker direction filter (opt-in, server-side pipeline) ========================================================== Add a direction field to MarkerCreate and MarkerDefinitionCreate schemas ("tx" | "rx" | null). Plumb it through the full pipeline: Schema -> controller (start_marker/update_marker, marker_entry, _markers_for_node, update_marker_definition sync) -> REST/MCP handlers -> compute _ubridge_add_marker_filter + IOU _ubridge_apply_markers -> bridge add_packet_filter dir When set, ubridge only fires the mark handler (signal + pcap) for packets matching the chosen direction. null (default/legacy) = both directions -- zero behavioural change for existing markers. Docs and tests ============== - docs/features/marker-traffic-insight.md: signal format updated, new Direction section with NIO mapping, arrow mapping, and additive compatibility note. - tests/compute/marker/test_marker_manager.py: 3 new parser tests (dir tx/rx/absent) plus existing test extended to assert dir=None. 13 files, +123/-22, 72 tests pass (zero breakage) --- docs/features/marker-traffic-insight.md | 30 ++++++++++++++++++-- gns3server/api/routes/controller/links.py | 2 ++ gns3server/api/routes/controller/projects.py | 2 ++ gns3server/api/routes/mcp/__init__.py | 6 ++-- gns3server/api/routes/mcp/links.py | 8 +++--- gns3server/compute/base_node.py | 7 +++-- gns3server/compute/iou/iou_vm.py | 3 ++ gns3server/compute/marker/marker_listener.py | 21 ++++++++++++-- gns3server/controller/link.py | 5 ++-- gns3server/controller/project.py | 10 ++++--- gns3server/controller/udp_link.py | 9 ++++-- gns3server/schemas/controller/links.py | 10 +++++++ tests/compute/marker/test_marker_manager.py | 30 ++++++++++++++++++++ 13 files changed, 122 insertions(+), 21 deletions(-) diff --git a/docs/features/marker-traffic-insight.md b/docs/features/marker-traffic-insight.md index 84ded58e1..42b16d177 100644 --- a/docs/features/marker-traffic-insight.md +++ b/docs/features/marker-traffic-insight.md @@ -100,6 +100,31 @@ pcap file, and its own `link=`. The shared bridge name is irrelevant to attribut capable node types (`qemu`, `docker`, `vpcs`, `cloud`) already use one bridge per link; `link` applies uniformly to all of them. +## Direction + +A `MARK` signal optionally carries `dir=` — the matched packet's travel direction +**relative to the capture node** (the `node=` in the same signal, i.e. the node whose +uBridge hosts the marker): + +| `dir` | Ingress NIO | Meaning | +|-------|-------------|---------| +| `tx` | device side (`source_nio` on a generic bridge; the IOL instance on an IOU `IOL-BRIDGE`) | capture node is **sending** | +| `rx` | link side (`destination_nio` on a generic bridge; the NIO side on an IOU `IOL-BRIDGE`) | capture node is **receiving** | + +A marker is single-sided: only the chosen capture node's uBridge installs the `mark` filter, +yet both directions of the link transit that one bridge (it carries exactly two NIOs — the +device side and the link side), so that single uBridge observes and classifies both +directions. The `marker.match` event forwards `dir` through unchanged; the Web UI combines it +with the link's two endpoints and the capture `node_id` to draw an arrow: + +- `dir=tx` → `capture_node → far_node` +- `dir=rx` → `far_node → capture_node` +- `dir` absent (older uBridge) → undirected highlight (current behaviour) + +Because the listener ignores unknown keys, `dir` is **additive**: an older server silently +drops it and an older uBridge simply omits it — either way the system falls back to +undirected rendering with no error. + ## API Endpoints All endpoints require a JWT bearer token (`POST /v3/access/users/authenticate`). The @@ -217,10 +242,11 @@ extra request. | Event | Payload | Delivered to | |-------|---------|--------------| | `link.updated` | Link object (its `markers` field is the source of truth) | Project notification ws | -| `marker.match` | `project_id`, `node_id`, `link_id`, `filter`, `tag`, `ts`, `len` | Project notification ws only | +| `marker.match` | `project_id`, `node_id`, `link_id`, `filter`, `tag`, `ts`, `len`, `dir` | Project notification ws only | The `marker.match` `link_id` is taken from the signal's `link=` field (authoritative); see -[Per-link attribution](#per-link-attribution). +[Per-link attribution](#per-link-attribution). The `dir` field is the matched packet's travel +direction relative to the capture node; see [Direction](#direction). ## Error Responses diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index 9e33b7ba3..8d9f105fb 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -465,6 +465,7 @@ async def create_marker( name=name, bpf=marker_data.bpf, tag=marker_data.tag, + direction=marker_data.direction, color=marker_data.color, highlight_duration=marker_data.highlight_duration, ) @@ -508,6 +509,7 @@ async def update_marker( name=marker_name, bpf=marker_data.bpf if marker_data.bpf else None, tag=marker_data.tag, + direction=marker_data.direction, color=marker_data.color, enabled=marker_data.enabled, highlight_duration=marker_data.highlight_duration, diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index 5e6f1c6de..de2e293a1 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -267,6 +267,7 @@ async def create_marker_definition( name=name, bpf=def_data.bpf, tag=def_data.tag, + direction=def_data.direction, color=def_data.color, highlight_duration=def_data.highlight_duration, ) @@ -292,6 +293,7 @@ async def update_marker_definition( name=def_name, bpf=def_data.bpf if def_data.bpf else None, tag=def_data.tag, + direction=def_data.direction, color=def_data.color, highlight_duration=def_data.highlight_duration, ) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index f7d57d6e2..e99b9eb2e 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -1015,6 +1015,7 @@ async def link_marker( name: Annotated[str | None, Field(description="Custom marker name for create action (auto-generated if omitted)")] = None, tag: Annotated[int | None, Field(description="Numeric tag for packet correlation")] = None, enabled: Annotated[bool | None, Field(description="Enable or disable the marker (for update action)")] = None, + direction: Annotated[str | None, Field(description="Direction filter: 'tx' for capture node sending only, 'rx' for capture node receiving only (omit for both)")] = None, color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None, highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None, ) -> list[dict[str, Any]]: @@ -1033,7 +1034,7 @@ async def link_marker( and cannot be modified or deleted via this tool. """ params = {"project_id": project_id, "link_id": link_id, "action": action} - for opt in ("bpf", "marker_name", "name", "tag", "enabled", "color", "highlight_duration"): + for opt in ("bpf", "marker_name", "name", "tag", "enabled", "direction", "color", "highlight_duration"): val = locals().get(opt) if val is not None: params[opt] = val @@ -1050,6 +1051,7 @@ async def marker_definition( tag: Annotated[int | None, Field(description="Numeric tag for packet correlation")] = None, color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None, highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None, + direction: Annotated[str | None, Field(description="Direction filter: 'tx' for capture node sending only, 'rx' for capture node receiving only (omit for both)")] = None, ) -> list[dict[str, Any]]: """Manage project-level marker definitions — traffic-insight rules that apply to ALL links. @@ -1065,7 +1067,7 @@ async def marker_definition( Common BPF examples: 'arp', 'icmp', 'ospf', 'tcp port 22', 'udp port 53' """ params = {"project_id": project_id, "action": action} - for opt in ("bpf", "def_name", "name", "tag", "color", "highlight_duration"): + for opt in ("bpf", "def_name", "name", "tag", "direction", "color", "highlight_duration"): val = locals().get(opt) if val is not None: params[opt] = val diff --git a/gns3server/api/routes/mcp/links.py b/gns3server/api/routes/mcp/links.py index b2a0352c3..c69d392ff 100644 --- a/gns3server/api/routes/mcp/links.py +++ b/gns3server/api/routes/mcp/links.py @@ -395,7 +395,7 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic if not bpf: return {"error": "bpf is required for create action"} body: dict[str, Any] = {"bpf": bpf} - for opt in ("name", "tag", "color", "highlight_duration"): + for opt in ("name", "tag", "direction", "color", "highlight_duration"): if params.get(opt) is not None: body[opt] = params[opt] return conn.http_call("post", base, json_data=body).json() @@ -408,7 +408,7 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic if action == "update": body = {} - for opt in ("bpf", "tag", "enabled", "color", "highlight_duration"): + for opt in ("bpf", "tag", "direction", "enabled", "color", "highlight_duration"): if params.get(opt) is not None: body[opt] = params[opt] if not body: @@ -448,7 +448,7 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) if not bpf: return {"error": "bpf is required for create action"} body: dict[str, Any] = {"bpf": bpf} - for opt in ("name", "tag", "color", "highlight_duration"): + for opt in ("name", "tag", "direction", "color", "highlight_duration"): if params.get(opt) is not None: body[opt] = params[opt] return conn.http_call("post", base, json_data=body).json() @@ -461,7 +461,7 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) if action == "update": body = {} - for opt in ("bpf", "tag", "color", "highlight_duration"): + for opt in ("bpf", "tag", "direction", "color", "highlight_duration"): if params.get(opt) is not None: body[opt] = params[opt] if not body: diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index df6fabddc..9b9ede270 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1084,7 +1084,7 @@ class BaseNode: ) i += 1 - async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None): + async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None, direction=None): """ Attach a `mark` packet filter to a uBridge bridge for traffic insight. @@ -1120,6 +1120,8 @@ class BaseNode: # so the link id is the only way to tell signals — and pcap files — apart. if link_id: cmd += f" link {link_id}" + if direction is not None: + cmd += f" dir {direction}" cmd += ' pcap "{path}"'.format(path=pcap_path) # Let BPF compile errors propagate — the marker is the user's intent, so a # bad expression must surface instead of being silently dropped. @@ -1149,7 +1151,8 @@ class BaseNode: markers_dir, f"{self._id}_{link_id}_{name}.pcap" ) try: - await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag, link_id) + await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag, link_id, + direction=spec.get("direction")) except UbridgeError as e: # Swallow BPF compile errors (warn + skip) so a single bad # expression can't break link creation / node restart — mirrors diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index 3697fc327..f2936f044 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -1308,6 +1308,9 @@ class IOUVM(BaseNode): # controller can tell their signals apart (contract §3.2). if link_id: cmd += f" link {link_id}" + direction = spec.get("direction") + if direction is not None: + cmd += f" dir {direction}" cmd += ' pcap "{path}"'.format(path=pcap_path) try: await self._ubridge_send(cmd) diff --git a/gns3server/compute/marker/marker_listener.py b/gns3server/compute/marker/marker_listener.py index f22f59bff..2b5e5d47f 100644 --- a/gns3server/compute/marker/marker_listener.py +++ b/gns3server/compute/marker/marker_listener.py @@ -28,9 +28,18 @@ class MarkerListener(asyncio.DatagramProtocol): Signal format (one datagram per match, ASCII):: - MARK node= filter= tag= len=\\n + MARK node= filter= tag= len= [dir=]\\n - The signal carries metadata only (no packet bytes). The compute-side + The signal carries metadata only (no packet bytes). ``dir`` is optional and + additive: uBridge stamps it from the ingress NIO of the matched packet to + indicate travel direction relative to the capture node (the ``node=`` + above) — ``tx`` = the capture node is sending (ingressed on the device-side + NIO), ``rx`` = it is receiving (ingressed on the link-side NIO). Older + uBridge builds omit it, so the listener leaves ``dir`` unset and consumers + fall back to undirected rendering. Unknown keys are always ignored, so the + field ships safely with no version coupling. + + The compute-side :class:`~gns3server.compute.marker.marker_manager.MarkerManager` registry resolves ``(node_id, filter_name)`` to ``(project_id, link_id, tag)`` so the event can be emitted on the right project-scoped notification stream. @@ -82,6 +91,11 @@ class MarkerListener(asyncio.DatagramProtocol): link = kv.get("link") tag = kv.get("tag") length = kv.get("len") + # Travel direction relative to the capture node (the node= above): + # "tx" = capture node is sending (matched packet ingressed on the + # device-side NIO), "rx" = it is receiving (link-side NIO). Older + # uBridge builds omit dir; None here lets consumers render undirected. + direction = kv.get("dir") project_id, link_id, registered_tag = self._manager.lookup(node_id, filter_name) if project_id is None: @@ -105,5 +119,8 @@ class MarkerListener(asyncio.DatagramProtocol): "tag": tag if tag and tag != "-" else registered_tag, "ts": ts, "len": int(length) if length and length.isdigit() else 0, + # Travel direction relative to the capture node (node_id above); + # None when the signal carries none (older uBridge) — undirected. + "dir": direction, } self._manager.emit_match(project_id, event) diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 008deda4d..a766e107c 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -121,6 +121,7 @@ class Link: name=f"global-{def_name}", bpf=marker_def["bpf"], tag=marker_def.get("tag"), + direction=marker_def.get("direction"), color=marker_def.get("color"), highlight_duration=marker_def.get("highlight_duration"), inherited_from=def_name, @@ -333,7 +334,7 @@ class Link: raise NotImplementedError - async def start_marker(self, name, bpf, tag=None): + async def start_marker(self, name, bpf, tag=None, direction=None): """ Attach a traffic-insight marker to this link (base — UDPLink overrides). """ @@ -345,7 +346,7 @@ class Link: """ raise NotImplementedError - async def update_marker(self, name, bpf=None, tag=None, enabled=None): + async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=None): """ Update an existing marker's BPF, tag, or enabled flag. diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 9ada6506a..10b94b620 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -930,7 +930,7 @@ class Project: """ return self._marker_definitions - async def create_marker_definition(self, name, bpf, tag=None, color=None, highlight_duration=None): + async def create_marker_definition(self, name, bpf, tag=None, direction=None, color=None, highlight_duration=None): """ Create a project-level marker definition and fan out to every existing link that has a capable node. Links without a capable node are silently @@ -942,12 +942,12 @@ class Project: f"Marker definition '{name}' already exists in this project" ) - self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "color": color, "highlight_duration": highlight_duration} + self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "direction": direction, "color": color, "highlight_duration": highlight_duration} await self._apply_def_to_all_links(name) self.dump() self.emit_notification("project.updated", self.asdict()) - async def update_marker_definition(self, name, bpf=None, tag=None, color=None, highlight_duration=None): + async def update_marker_definition(self, name, bpf=None, tag=None, direction=None, color=None, highlight_duration=None): """ Update a marker definition and sync every inherited copy on every link. """ @@ -966,13 +966,15 @@ class Project: d["color"] = color if highlight_duration is not None: d["highlight_duration"] = highlight_duration + if direction is not None: + d["direction"] = direction # Sync: update every inherited copy across all links. for link in list(self._links.values()): marker_name = f"global-{name}" if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name: await link.update_marker( - marker_name, bpf=d["bpf"], tag=d.get("tag"), color=d.get("color"), + marker_name, bpf=d["bpf"], tag=d.get("tag"), direction=d.get("direction"), color=d.get("color"), highlight_duration=d.get("highlight_duration"), inherited=True ) self.dump() diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 609c17495..146f55de6 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -62,7 +62,7 @@ class UDPLink(Link): marker only rides the NIO of the node whose uBridge will host it. """ return { - name: {"bpf": m["bpf"], "tag": m.get("tag"), "link_id": self._id} + name: {"bpf": m["bpf"], "tag": m.get("tag"), "link_id": self._id, "direction": m.get("direction")} for name, m in self._markers.items() if m.get("enabled", True) and m.get("capture_node_id") == node.id } @@ -322,7 +322,7 @@ class UDPLink(Link): # explicitly deletes a marker via the REST API, and a marker is torn # down automatically only when its link is deleted. - async def start_marker(self, name, bpf, tag=None, color=None, highlight_duration=None, inherited_from=None): + async def start_marker(self, name, bpf, tag=None, direction=None, color=None, highlight_duration=None, inherited_from=None): """ Attach a traffic-insight marker to this link. @@ -358,6 +358,7 @@ class UDPLink(Link): "color": color, "highlight_duration": highlight_duration, "capture_node_id": marker_side["node"].id, + "direction": direction, } if inherited_from: marker_entry["inherited_from"] = inherited_from @@ -396,7 +397,7 @@ class UDPLink(Link): self._project.emit_notification("link.updated", self.asdict()) self._project.dump() - async def update_marker(self, name, bpf=None, tag=None, enabled=None, color=None, highlight_duration=None, inherited=False): + async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=None, color=None, highlight_duration=None, inherited=False): """ Update an existing marker's BPF/tag/enabled/color. Any change pushes via ``self.update()``; uBridge picks up the new params on the next NIO @@ -436,6 +437,8 @@ class UDPLink(Link): marker_info["color"] = color if highlight_duration is not None: marker_info["highlight_duration"] = highlight_duration + if direction is not None: + marker_info["direction"] = direction if self._created: await self.update() diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 23a0cd18a..44f8ac1aa 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -175,6 +175,11 @@ class MarkerCreate(BaseModel): None, description="Whether the marker is active. Defaults to true on creation.", ) + direction: Optional[str] = Field( + None, + pattern=r"^(tx|rx)$", + description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only. Omitted or null = both directions.", + ) class MarkerDefinitionCreate(BaseModel): @@ -207,5 +212,10 @@ class MarkerDefinitionCreate(BaseModel): "stored with the definition, never sent to uBridge." ), ) + direction: Optional[str] = Field( + None, + pattern=r"^(tx|rx)$", + description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only. Omitted or null = both directions.", + ) diff --git a/tests/compute/marker/test_marker_manager.py b/tests/compute/marker/test_marker_manager.py index 4a2796002..da495ae92 100644 --- a/tests/compute/marker/test_marker_manager.py +++ b/tests/compute/marker/test_marker_manager.py @@ -124,6 +124,8 @@ class TestMarkerListener: assert ev["tag"] == "7" assert ev["ts"] == pytest.approx(1700000000.123456) assert ev["len"] == 98 + # No dir= in the signal (legacy uBridge) → undirected. + assert ev["dir"] is None def test_unknown_node_dropped(self): fmgr = FakeMarkerManager() @@ -183,6 +185,34 @@ class TestMarkerListener: lis.datagram_received(b"MARK 3.0 node=n filter=f link=- tag=1 len=42\n", None) assert fmgr.events[0][1]["link_id"] == "registry-link" + def test_dir_tx_passthrough(self): + # dir=tx = capture node sending (matched packet ingressed the device-side NIO). + fmgr = FakeMarkerManager() + fmgr.register("p", "n", "f", "l", tag=1) + lis = MarkerListener(fmgr) + lis.connection_made(None) + lis.datagram_received(b"MARK 1.0 node=n filter=f tag=1 len=10 dir=tx\n", None) + assert fmgr.events[0][1]["dir"] == "tx" + + def test_dir_rx_passthrough(self): + # dir=rx = capture node receiving (matched packet ingressed the link-side NIO). + fmgr = FakeMarkerManager() + fmgr.register("p", "n", "f", "l", tag=1) + lis = MarkerListener(fmgr) + lis.connection_made(None) + lis.datagram_received(b"MARK 1.0 node=n filter=f tag=1 len=10 dir=rx\n", None) + assert fmgr.events[0][1]["dir"] == "rx" + + def test_dir_absent_is_none(self): + # Older uBridge builds omit dir; the event then carries None so the UI + # falls back to undirected rendering. + fmgr = FakeMarkerManager() + fmgr.register("p", "n", "f", "l", tag=1) + lis = MarkerListener(fmgr) + lis.connection_made(None) + lis.datagram_received(b"MARK 1.0 node=n filter=f tag=1 len=10\n", None) + assert fmgr.events[0][1]["dir"] is None + def test_exception_does_not_kill_listener(self): fmgr = FakeMarkerManager() lis = MarkerListener(fmgr) From 71fa778d50f478c99e66a2dad640860f7d870c73 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 1 Aug 2026 22:24:47 +0800 Subject: [PATCH 05/36] marker: let callers pin the capture node via capture_node_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A marker is single-sided — only the chosen capture node's uBridge installs the mark filter — and dir=tx|rx is interpreted from that node's perspective. Until now the observer was always auto-picked (_choose_marker_side), so dir=tx meant "the auto-chosen endpoint is sending", which is unpredictable and makes the direction filter hard to render meaningfully in the Web UI. Add an optional create-only capture_node_id to MarkerCreate: when set, the marker is pinned to that endpoint's uBridge (validated as a link endpoint and a marker-capable type); when omitted, behavior is unchanged (auto-pick). The chosen id is already echoed back as capture_node_id and in MARK signals, so the UI can always render the observer regardless of who picked it. capture_node_id is create-only (changing it would silently flip the meaning of stored direction; recreate instead) and is not accepted on project-level definitions — they are link-agnostic and have no endpoints, so inherited markers keep auto-picking per link. Plumbed through REST create_marker, the MCP link_marker tool, and base Link.start_marker. update_marker does not forward it. --- docs/features/marker-traffic-insight.md | 30 +++++++++++++++++- gns3server/api/routes/controller/links.py | 1 + gns3server/api/routes/mcp/__init__.py | 3 +- gns3server/api/routes/mcp/links.py | 2 +- gns3server/controller/link.py | 2 +- gns3server/controller/udp_link.py | 37 +++++++++++++++++++++-- gns3server/schemas/controller/links.py | 9 ++++++ tests/controller/test_marker.py | 34 +++++++++++++++++++++ 8 files changed, 112 insertions(+), 6 deletions(-) diff --git a/docs/features/marker-traffic-insight.md b/docs/features/marker-traffic-insight.md index 42b16d177..a91091bf4 100644 --- a/docs/features/marker-traffic-insight.md +++ b/docs/features/marker-traffic-insight.md @@ -125,6 +125,28 @@ Because the listener ignores unknown keys, `dir` is **additive**: an older serve drops it and an older uBridge simply omits it — either way the system falls back to undirected rendering with no error. +### Choosing the capture node + +Since `dir` is relative to the capture node, *which* endpoint is the observer decides what +`tx`/`rx` mean. By default the server auto-picks (first started marker-capable endpoint, in +link-endpoint order). To pin it — e.g. so `dir=tx` unambiguously means "vpcs1 is sending" — +pass `capture_node_id` on marker **create**: + +```json +{ "bpf": "icmp", "direction": "tx", "capture_node_id": "" } +``` + +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 +the auto-pick. The chosen id is echoed back as `capture_node_id` in the marker entry and in +each `MARK` signal's `node=`, so the Web UI always knows the observer regardless of who +picked it. + +`capture_node_id` is **create-only**: it is fixed once the marker exists (changing the +observer would silently flip the meaning of stored `direction`, so recreate the marker +instead). It is not accepted on project-level definitions — a definition is link-agnostic and +has no endpoints to choose from, so inherited markers always auto-pick per link. + ## API Endpoints All endpoints require a JWT bearer token (`POST /v3/access/users/authenticate`). The @@ -167,12 +189,17 @@ extra request. "name": "icmp", "bpf": "icmp", "tag": 1, + "direction": "tx", + "capture_node_id": "a37e2235-e21f-46c9-a2ab-ba0f8c5465e6", "color": "#ff5722", "highlight_duration": 800, "enabled": true } ``` +`direction` and `capture_node_id` are both optional and create-only (see +[Direction](#direction)). + **Definition create body** (`MarkerDefinitionCreate`, shared by POST and PUT): ```json @@ -224,7 +251,8 @@ extra request. | `enabled` | bool | Whether the marker is active | | `color` | string \| null | Hex color render hint, e.g. `#ff5722` | | `highlight_duration` | int \| null | UI highlight duration in ms after a match; `null` = UI default | -| `capture_node_id` | string | Server-chosen node whose uBridge hosts the marker | +| `direction` | string \| null | `tx` / `rx` filter relative to the capture node; `null` = both | +| `capture_node_id` | string | Node whose uBridge hosts the marker — caller-set on create, else auto-picked | | `inherited_from` | string | Source definition name — present on inherited markers only | ### Definition diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index 8d9f105fb..99ff3379c 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -466,6 +466,7 @@ async def create_marker( bpf=marker_data.bpf, tag=marker_data.tag, direction=marker_data.direction, + capture_node_id=marker_data.capture_node_id, color=marker_data.color, highlight_duration=marker_data.highlight_duration, ) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index e99b9eb2e..4cf389c13 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -1016,6 +1016,7 @@ async def link_marker( tag: Annotated[int | None, Field(description="Numeric tag for packet correlation")] = None, enabled: Annotated[bool | None, Field(description="Enable or disable the marker (for update action)")] = None, direction: Annotated[str | None, Field(description="Direction filter: 'tx' for capture node sending only, 'rx' for capture node receiving only (omit for both)")] = None, + capture_node_id: Annotated[str | None, Field(description="UUID of the endpoint whose uBridge hosts the marker (the observer; tx/rx are from its perspective). Must be a link endpoint and marker-capable. Omit to auto-pick.")] = None, color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None, highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None, ) -> list[dict[str, Any]]: @@ -1034,7 +1035,7 @@ async def link_marker( and cannot be modified or deleted via this tool. """ params = {"project_id": project_id, "link_id": link_id, "action": action} - for opt in ("bpf", "marker_name", "name", "tag", "enabled", "direction", "color", "highlight_duration"): + for opt in ("bpf", "marker_name", "name", "tag", "enabled", "direction", "capture_node_id", "color", "highlight_duration"): val = locals().get(opt) if val is not None: params[opt] = val diff --git a/gns3server/api/routes/mcp/links.py b/gns3server/api/routes/mcp/links.py index c69d392ff..2b854b07f 100644 --- a/gns3server/api/routes/mcp/links.py +++ b/gns3server/api/routes/mcp/links.py @@ -395,7 +395,7 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic if not bpf: return {"error": "bpf is required for create action"} body: dict[str, Any] = {"bpf": bpf} - for opt in ("name", "tag", "direction", "color", "highlight_duration"): + for opt in ("name", "tag", "direction", "capture_node_id", "color", "highlight_duration"): if params.get(opt) is not None: body[opt] = params[opt] return conn.http_call("post", base, json_data=body).json() diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index a766e107c..c75a8c10d 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -334,7 +334,7 @@ class Link: raise NotImplementedError - async def start_marker(self, name, bpf, tag=None, direction=None): + async def start_marker(self, name, bpf, tag=None, direction=None, capture_node_id=None): """ Attach a traffic-insight marker to this link (base — UDPLink overrides). """ diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 146f55de6..5e72831a5 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -310,6 +310,31 @@ class UDPLink(Link): "traffic insight" ) + def _node_by_id(self, node_id): + """ + Resolve a caller-chosen capture node by id, validating it is an + endpoint of this link and marker-capable. Used when the caller + (REST/MCP) explicitly pins the observer side instead of letting + ``_choose_marker_side`` auto-pick. + + :param node_id: node id (UUID or str) the caller requested + :returns: a ``self._nodes`` entry (node/adapter_number/port_number) + """ + + target = str(node_id) + for node in self._nodes: + if str(node["node"].id) != target: + continue + if node["node"].node_type not in _MARKER_CAPABLE_TYPES: + raise ControllerError( + f"Node {node_id} ({node['node'].node_type}) cannot host a " + f"marker — no uBridge bridge to attach the filter to" + ) + return node + raise ControllerNotFoundError( + f"Node {node_id} is not an endpoint of link {self._id}" + ) + async def node_updated(self, node): """ Called when a node member of the link is updated @@ -322,7 +347,7 @@ class UDPLink(Link): # explicitly deletes a marker via the REST API, and a marker is torn # down automatically only when its link is deleted. - async def start_marker(self, name, bpf, tag=None, direction=None, color=None, highlight_duration=None, inherited_from=None): + async def start_marker(self, name, bpf, tag=None, direction=None, capture_node_id=None, color=None, highlight_duration=None, inherited_from=None): """ Attach a traffic-insight marker to this link. @@ -335,6 +360,11 @@ class UDPLink(Link): :param name: stable filter name — echoed in MARK signals + pcap identity :param bpf: libpcap BPF expression :param tag: optional correlation id + :param capture_node_id: optional explicit observer node. When set the + marker is pinned to that endpoint's uBridge (and ``direction`` is + interpreted from its perspective); validated by ``_node_by_id``. + Omitted = auto-pick via ``_choose_marker_side``. Ignored for + inherited markers (project defs are link-agnostic → always auto). :param color: optional hex color for the Web UI (e.g. '#ff5722'); stored with the link and persisted in the topology, never sent to uBridge :param highlight_duration: optional UI-only hint (milliseconds) for how @@ -350,7 +380,10 @@ class UDPLink(Link): if not result.get("valid"): raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}") - marker_side = self._choose_marker_side() + if capture_node_id and not inherited_from: + marker_side = self._node_by_id(capture_node_id) + else: + marker_side = self._choose_marker_side() marker_entry = { "bpf": bpf, "tag": tag, diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 44f8ac1aa..3ec06c5d5 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -180,6 +180,15 @@ class MarkerCreate(BaseModel): pattern=r"^(tx|rx)$", description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only. Omitted or null = both directions.", ) + capture_node_id: Optional[UUID] = Field( + None, + description=( + "Which endpoint's uBridge hosts this marker (the 'observer'). " + "tx/rx in `direction` are interpreted from this node's perspective. " + "Must be one of the link's two endpoints and a marker-capable type. " + "Omitted = server auto-picks (first started marker-capable endpoint)." + ), + ) class MarkerDefinitionCreate(BaseModel): diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py index 7dc0ebe13..6348a7a48 100644 --- a/tests/controller/test_marker.py +++ b/tests/controller/test_marker.py @@ -98,6 +98,40 @@ async def test_start_marker_stores_entry(project): assert "inherited_from" not in entry +@pytest.mark.asyncio +async def test_start_marker_pins_capture_node(project): + # Auto-pick would choose node1 (first endpoint); pin to node2 explicitly. + with _valid_bpf(): + link = await _make_link(project) + chosen = link._nodes[1]["node"].id + auto = link._nodes[0]["node"].id + assert chosen != auto # sanity: the pin must actually mean something + await link.start_marker("icmp", "icmp", capture_node_id=chosen) + + assert link.markers["icmp"]["capture_node_id"] == chosen + + +@pytest.mark.asyncio +async def test_start_marker_rejects_non_endpoint_capture_node(project): + + with _valid_bpf(): + link = await _make_link(project) + with pytest.raises(ControllerNotFoundError): + await link.start_marker("icmp", "icmp", capture_node_id="11111111-2222-3333-4444-555555555555") + + +@pytest.mark.asyncio +async def test_start_marker_capture_node_ignored_for_inherited(project): + # Definitions are link-agnostic: an inherited marker must auto-pick even + # if a capture_node_id leaks through, never trusting the caller's pin. + with _valid_bpf(): + link = await _make_link(project) + leaked = link._nodes[1]["node"].id + await link.start_marker("m", "icmp", capture_node_id=leaked, inherited_from="arp") + + assert link.markers["m"]["capture_node_id"] == link._nodes[0]["node"].id + + @pytest.mark.asyncio async def test_start_marker_rejects_duplicate(project): From 37cb9f0a9c6100cc7d11424ccd95a8395f86f031 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 2 Aug 2026 11:37:49 +0800 Subject: [PATCH 06/36] marker: support clearing direction via explicit null / "both" direction was settable but not clearable: once a marker or project-level definition had direction=tx/rx, no update path could return it to "both directions", and the definition-sync fan-out silently kept stale values on every inherited copy. Introduce a _UNSET sentinel (link.py) distinct from None so updaters can tell "caller omitted direction" (preserve) from "caller passed None" (clear). Thread it through UDPLink.update_marker and Project.update_marker_definition; the two REST routes use Pydantic v2 model_fields_set to translate an explicit JSON null into the sentinel. MCP follows with a "both" token: link_marker / marker_definition handlers map direction="both" to a null in the REST body (tri-state: omit=preserve, tx/rx=set, both=clear), and the tool descriptions/docstrings document it. Backward compatible: omitting direction or passing tx/rx behaves exactly as before; only an explicit null / "both" clears. --- gns3server/api/routes/controller/links.py | 4 +-- gns3server/api/routes/controller/projects.py | 3 +- gns3server/api/routes/mcp/__init__.py | 8 +++--- gns3server/api/routes/mcp/links.py | 30 ++++++++++++++++---- gns3server/controller/link.py | 9 +++++- gns3server/controller/project.py | 7 +++-- gns3server/controller/udp_link.py | 8 +++--- 7 files changed, 48 insertions(+), 21 deletions(-) diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index 99ff3379c..1bddff2d1 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -32,7 +32,7 @@ from uuid import UUID, uuid4 from gns3server.controller import Controller from gns3server.controller.controller_error import ControllerError from gns3server.db.repositories.rbac import RbacRepository -from gns3server.controller.link import Link +from gns3server.controller.link import Link, _UNSET from gns3server.utils.http_client import HTTPClient from gns3server.utils.port_allocator import link_id_to_port from gns3server.utils.websocket_to_websocket import websocket_proxy @@ -510,7 +510,7 @@ async def update_marker( name=marker_name, bpf=marker_data.bpf if marker_data.bpf else None, tag=marker_data.tag, - direction=marker_data.direction, + direction=marker_data.direction if "direction" in marker_data.model_fields_set else _UNSET, color=marker_data.color, enabled=marker_data.enabled, highlight_duration=marker_data.highlight_duration, diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index de2e293a1..33ad90902 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -40,6 +40,7 @@ from uuid import UUID from gns3server import schemas from gns3server.controller import Controller from gns3server.controller.project import Project +from gns3server.controller.link import _UNSET from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError from gns3server.controller.import_project import import_project as import_controller_project from gns3server.controller.export_project import export_project as export_controller_project @@ -293,7 +294,7 @@ async def update_marker_definition( name=def_name, bpf=def_data.bpf if def_data.bpf else None, tag=def_data.tag, - direction=def_data.direction, + direction=def_data.direction if "direction" in def_data.model_fields_set else _UNSET, color=def_data.color, highlight_duration=def_data.highlight_duration, ) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 4cf389c13..3f2425b00 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -1015,7 +1015,7 @@ async def link_marker( name: Annotated[str | None, Field(description="Custom marker name for create action (auto-generated if omitted)")] = None, tag: Annotated[int | None, Field(description="Numeric tag for packet correlation")] = None, enabled: Annotated[bool | None, Field(description="Enable or disable the marker (for update action)")] = None, - direction: Annotated[str | None, Field(description="Direction filter: 'tx' for capture node sending only, 'rx' for capture node receiving only (omit for both)")] = None, + direction: Annotated[str | None, Field(description="Direction filter: 'tx' (capture node sending only), 'rx' (receiving only), or 'both' (no filter — on update this clears a previously set direction). Omit to leave unchanged on update.")] = None, capture_node_id: Annotated[str | None, Field(description="UUID of the endpoint whose uBridge hosts the marker (the observer; tx/rx are from its perspective). Must be a link endpoint and marker-capable. Omit to auto-pick.")] = None, color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None, highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None, @@ -1026,7 +1026,7 @@ async def link_marker( Set action='create' to add a marker, 'update' to modify it, 'delete' to remove. Create requires: project_id, link_id, action='create', bpf - Update requires: project_id, link_id, action='update', marker_name, and at least one of (bpf, tag, enabled, color, highlight_duration) + Update requires: project_id, link_id, action='update', marker_name, and at least one of (bpf, tag, enabled, direction, color, highlight_duration) Delete requires: project_id, link_id, action='delete', marker_name To read current markers, use link_get — the response includes a 'markers' dict. @@ -1052,7 +1052,7 @@ async def marker_definition( tag: Annotated[int | None, Field(description="Numeric tag for packet correlation")] = None, color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None, highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None, - direction: Annotated[str | None, Field(description="Direction filter: 'tx' for capture node sending only, 'rx' for capture node receiving only (omit for both)")] = None, + direction: Annotated[str | None, Field(description="Direction filter: 'tx' (capture node sending only), 'rx' (receiving only), or 'both' (no filter — on update this clears a previously set direction). Omit to leave unchanged on update.")] = None, ) -> list[dict[str, Any]]: """Manage project-level marker definitions — traffic-insight rules that apply to ALL links. @@ -1061,7 +1061,7 @@ async def marker_definition( On delete, 'global-{name}' is removed from every link. Create requires: project_id, action='create', bpf - Update requires: project_id, action='update', def_name, and at least one of (bpf, tag, color, highlight_duration) + Update requires: project_id, action='update', def_name, and at least one of (bpf, tag, direction, color, highlight_duration) Delete requires: project_id, action='delete', def_name List requires: project_id, action='list' diff --git a/gns3server/api/routes/mcp/links.py b/gns3server/api/routes/mcp/links.py index 2b854b07f..9ddf2b377 100644 --- a/gns3server/api/routes/mcp/links.py +++ b/gns3server/api/routes/mcp/links.py @@ -395,9 +395,12 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic if not bpf: return {"error": "bpf is required for create action"} body: dict[str, Any] = {"bpf": bpf} - for opt in ("name", "tag", "direction", "capture_node_id", "color", "highlight_duration"): + for opt in ("name", "tag", "capture_node_id", "color", "highlight_duration"): if params.get(opt) is not None: body[opt] = params[opt] + # direction: "tx"/"rx" set a one-way filter; "both"/omitted = no filter. + if params.get("direction") in ("tx", "rx"): + body["direction"] = params["direction"] return conn.http_call("post", base, json_data=body).json() marker_name = params.get("marker_name") @@ -408,11 +411,17 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic if action == "update": body = {} - for opt in ("bpf", "tag", "direction", "enabled", "color", "highlight_duration"): + for opt in ("bpf", "tag", "enabled", "color", "highlight_duration"): if params.get(opt) is not None: body[opt] = params[opt] + # direction tri-state: omitted=preserve, "tx"/"rx"=set, "both"=clear (→ null). + direction = params.get("direction") + if direction == "both": + body["direction"] = None + elif direction in ("tx", "rx"): + body["direction"] = direction if not body: - return {"error": "At least one update field is required (bpf, tag, enabled, color, highlight_duration)"} + return {"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"} return conn.http_call("put", url, json_data=body).json() # action == "delete" @@ -448,9 +457,12 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) if not bpf: return {"error": "bpf is required for create action"} body: dict[str, Any] = {"bpf": bpf} - for opt in ("name", "tag", "direction", "color", "highlight_duration"): + for opt in ("name", "tag", "color", "highlight_duration"): if params.get(opt) is not None: body[opt] = params[opt] + # direction: "tx"/"rx" set a one-way filter; "both"/omitted = no filter. + if params.get("direction") in ("tx", "rx"): + body["direction"] = params["direction"] return conn.http_call("post", base, json_data=body).json() def_name = params.get("def_name") @@ -461,11 +473,17 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) if action == "update": body = {} - for opt in ("bpf", "tag", "direction", "color", "highlight_duration"): + for opt in ("bpf", "tag", "color", "highlight_duration"): if params.get(opt) is not None: body[opt] = params[opt] + # direction tri-state: omitted=preserve, "tx"/"rx"=set, "both"=clear (→ null). + direction = params.get("direction") + if direction == "both": + body["direction"] = None + elif direction in ("tx", "rx"): + body["direction"] = direction if not body: - return {"error": "At least one update field is required (bpf, tag, color, highlight_duration)"} + return {"error": "At least one update field is required (bpf, tag, direction, color, highlight_duration)"} return conn.http_call("put", url, json_data=body).json() # action == "delete" diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index c75a8c10d..743abc676 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -30,6 +30,13 @@ import logging log = logging.getLogger(__name__) +# Sentinel for "argument not passed". Distinct from None so marker/definition +# updaters can tell "caller omitted direction" (keep current value) from +# "caller passed direction=None" (clear it back to both directions). See +# UDPLink.update_marker and Project.update_marker_definition. +_UNSET = object() + + FILTERS = [ { "type": "frequency_drop", @@ -346,7 +353,7 @@ class Link: """ raise NotImplementedError - async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=None): + async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=_UNSET): """ Update an existing marker's BPF, tag, or enabled flag. diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 10b94b620..aa1cbd82d 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -37,6 +37,7 @@ from .snapshot import Snapshot from .drawing import Drawing from .topology import project_to_topology, load_topology from .udp_link import UDPLink +from .link import _UNSET from ..config import Config from ..utils.path import check_path_allowed, get_default_project_directory from ..utils.application_id import get_next_application_id @@ -947,7 +948,7 @@ class Project: self.dump() self.emit_notification("project.updated", self.asdict()) - async def update_marker_definition(self, name, bpf=None, tag=None, direction=None, color=None, highlight_duration=None): + async def update_marker_definition(self, name, bpf=None, tag=None, direction=_UNSET, color=None, highlight_duration=None): """ Update a marker definition and sync every inherited copy on every link. """ @@ -966,8 +967,8 @@ class Project: d["color"] = color if highlight_duration is not None: d["highlight_duration"] = highlight_duration - if direction is not None: - d["direction"] = direction + if direction is not _UNSET: + d["direction"] = direction # None = clear back to both directions # Sync: update every inherited copy across all links. for link in list(self._links.values()): diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 5e72831a5..e3764e317 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -17,7 +17,7 @@ from .controller_error import ControllerError, ControllerNotFoundError -from .link import Link +from .link import Link, _UNSET from .node_types import BUILTIN_NODE_TYPES from gns3server.utils.packet_filter_validation import validate_bpf_syntax, FilterValidationError @@ -430,7 +430,7 @@ class UDPLink(Link): self._project.emit_notification("link.updated", self.asdict()) self._project.dump() - async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=None, color=None, highlight_duration=None, inherited=False): + async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=_UNSET, color=None, highlight_duration=None, inherited=False): """ Update an existing marker's BPF/tag/enabled/color. Any change pushes via ``self.update()``; uBridge picks up the new params on the next NIO @@ -470,8 +470,8 @@ class UDPLink(Link): marker_info["color"] = color if highlight_duration is not None: marker_info["highlight_duration"] = highlight_duration - if direction is not None: - marker_info["direction"] = direction + if direction is not _UNSET: + marker_info["direction"] = direction # None = clear back to both directions if self._created: await self.update() From 7368e4a65b9113fb5a8c5ec34e2764b98ccaa8c0 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 2 Aug 2026 11:37:49 +0800 Subject: [PATCH 07/36] marker: add direction-clear, routing, and ubridge transport tests Close the test gaps surfaced in review: - controller (test_marker.py): direction clear vs preserve, definition-sync clear propagation, pinned-marker NIO routing, _markers_for_node carrying direction, and non-capable capture-node rejection. - ubridge (new tests/compute/ubridge/test_hypervisor.py): unix/tcp __init__ branches + socket-dir perms, _build_command -U/-H/debug, endpoint, stop socket unlink, and start() immediate-exit fail-fast. - mcp (test_handlers.py): TestLinkMarker / TestMarkerDefinition covering direction tri-state (both/tx/omitted) for create and update. --- tests/api/routes/mcp/test_handlers.py | 155 ++++++++++++++++++ tests/compute/ubridge/__init__.py | 0 tests/compute/ubridge/test_hypervisor.py | 191 +++++++++++++++++++++++ tests/controller/test_marker.py | 83 ++++++++++ 4 files changed, 429 insertions(+) create mode 100644 tests/compute/ubridge/__init__.py create mode 100644 tests/compute/ubridge/test_hypervisor.py diff --git a/tests/api/routes/mcp/test_handlers.py b/tests/api/routes/mcp/test_handlers.py index c181c1c87..5cd8a8bc0 100644 --- a/tests/api/routes/mcp/test_handlers.py +++ b/tests/api/routes/mcp/test_handlers.py @@ -359,3 +359,158 @@ class TestTemplate: m.return_value = _mock_conn({}) result = delete_template_handler({"template_id": "t1"}, ctx) assert "deleted" in str(result).lower() + + +# ── Marker (traffic-insight) ──────────────────────────────────────────── + + +class TestLinkMarker: + """link_marker_handler direction tri-state: omit=preserve, tx/rx=set, both=clear (→ null).""" + + mod = "links" + + def test_update_direction_both_clears(self, ctx): + from gns3server.api.routes.mcp.links import link_marker_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "icmp"}) + m.return_value = conn + link_marker_handler( + {"project_id": "p", "link_id": "l", "action": "update", + "marker_name": "icmp", "direction": "both"}, ctx, + ) + conn.http_call.assert_called_with( + "put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp", + json_data={"direction": None}, + ) + + def test_update_direction_tx_sets(self, ctx): + from gns3server.api.routes.mcp.links import link_marker_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "icmp"}) + m.return_value = conn + link_marker_handler( + {"project_id": "p", "link_id": "l", "action": "update", + "marker_name": "icmp", "direction": "tx"}, ctx, + ) + conn.http_call.assert_called_with( + "put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp", + json_data={"direction": "tx"}, + ) + + def test_update_direction_omitted_preserved(self, ctx): + from gns3server.api.routes.mcp.links import link_marker_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "icmp"}) + m.return_value = conn + link_marker_handler( + {"project_id": "p", "link_id": "l", "action": "update", + "marker_name": "icmp", "tag": 1}, ctx, + ) + conn.http_call.assert_called_with( + "put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp", + json_data={"tag": 1}, + ) + + def test_create_direction_both_omitted(self, ctx): + from gns3server.api.routes.mcp.links import link_marker_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "icmp"}) + m.return_value = conn + link_marker_handler( + {"project_id": "p", "link_id": "l", "action": "create", + "bpf": "icmp", "direction": "both"}, ctx, + ) + conn.http_call.assert_called_with( + "post", "http://192.168.1.3:3080/v3/projects/p/links/l/markers", + json_data={"bpf": "icmp"}, + ) + + def test_create_direction_tx(self, ctx): + from gns3server.api.routes.mcp.links import link_marker_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "icmp"}) + m.return_value = conn + link_marker_handler( + {"project_id": "p", "link_id": "l", "action": "create", + "bpf": "icmp", "direction": "tx"}, ctx, + ) + conn.http_call.assert_called_with( + "post", "http://192.168.1.3:3080/v3/projects/p/links/l/markers", + json_data={"bpf": "icmp", "direction": "tx"}, + ) + + +class TestMarkerDefinition: + """marker_definition_handler direction tri-state (same semantics as link markers).""" + + mod = "links" + + def test_update_direction_both_clears(self, ctx): + from gns3server.api.routes.mcp.links import marker_definition_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "arp"}) + m.return_value = conn + marker_definition_handler( + {"project_id": "p", "action": "update", + "def_name": "arp", "direction": "both"}, ctx, + ) + conn.http_call.assert_called_with( + "put", "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp", + json_data={"direction": None}, + ) + + def test_update_direction_tx_sets(self, ctx): + from gns3server.api.routes.mcp.links import marker_definition_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "arp"}) + m.return_value = conn + marker_definition_handler( + {"project_id": "p", "action": "update", + "def_name": "arp", "direction": "tx"}, ctx, + ) + conn.http_call.assert_called_with( + "put", "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp", + json_data={"direction": "tx"}, + ) + + def test_update_direction_omitted_preserved(self, ctx): + from gns3server.api.routes.mcp.links import marker_definition_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "arp"}) + m.return_value = conn + marker_definition_handler( + {"project_id": "p", "action": "update", + "def_name": "arp", "tag": 1}, ctx, + ) + conn.http_call.assert_called_with( + "put", "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp", + json_data={"tag": 1}, + ) + + def test_create_direction_both_omitted(self, ctx): + from gns3server.api.routes.mcp.links import marker_definition_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "arp"}) + m.return_value = conn + marker_definition_handler( + {"project_id": "p", "action": "create", + "bpf": "arp", "direction": "both"}, ctx, + ) + conn.http_call.assert_called_with( + "post", "http://192.168.1.3:3080/v3/projects/p/marker-definitions", + json_data={"bpf": "arp"}, + ) + + def test_create_direction_tx(self, ctx): + from gns3server.api.routes.mcp.links import marker_definition_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"name": "arp"}) + m.return_value = conn + marker_definition_handler( + {"project_id": "p", "action": "create", + "bpf": "arp", "direction": "tx"}, ctx, + ) + conn.http_call.assert_called_with( + "post", "http://192.168.1.3:3080/v3/projects/p/marker-definitions", + json_data={"bpf": "arp", "direction": "tx"}, + ) diff --git a/tests/compute/ubridge/__init__.py b/tests/compute/ubridge/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/compute/ubridge/test_hypervisor.py b/tests/compute/ubridge/test_hypervisor.py new file mode 100644 index 000000000..126278ab0 --- /dev/null +++ b/tests/compute/ubridge/test_hypervisor.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python +# +# Copyright (C) 2025 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 . + +""" +Tests for the uBridge ``Hypervisor`` wrapper — the configurable control-channel +transport (AF_UNIX ``-U`` vs TCP ``-H``), command building, the human-readable +``endpoint``, socket cleanup on stop, and the fail-fast detection of an +immediately-exiting uBridge process (e.g. an old build that rejects ``-U``). +""" + +import os +import re +import stat +import logging + +import pytest +from unittest.mock import MagicMock, AsyncMock, patch + +from gns3server.compute.ubridge.hypervisor import Hypervisor +from gns3server.compute.ubridge.ubridge_error import UbridgeError + + +def _make(transport, tmp_path, monkeypatch, node_id="abc123", host="127.0.0.1"): + """Build a Hypervisor with ``XDG_RUNTIME_DIR`` pinned to ``tmp_path``. + + The unix transport creates its socket dir under ``$XDG_RUNTIME_DIR/gns3``; + pinning it keeps creation predictable and avoids touching the real runtime + dir. ``host`` is unused for the unix transport but always accepted. + """ + + monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path)) + return Hypervisor(MagicMock(), "ubridge", str(tmp_path), transport, host=host, node_id=node_id) + + +# --------------------------------------------------------------------------- +# __init__: transport selection +# --------------------------------------------------------------------------- + +def test_init_unix_creates_socket_dir_and_path(tmp_path, monkeypatch): + + hyp = _make("unix", tmp_path, monkeypatch, node_id="abc123") + assert hyp._socket_path == str(tmp_path / "gns3" / "ubridge-abc123.sock") + + socket_dir = os.path.dirname(hyp._socket_path) + assert os.path.isdir(socket_dir) + # 0o700 regardless of umask — __init__ chmods explicitly. + assert stat.S_IMODE(os.stat(socket_dir).st_mode) == 0o700 + # TCP-only attributes are unused on the unix transport. + assert hyp._host is None + assert hyp._port is None + + +def test_init_unix_fallback_name_without_node_id(tmp_path, monkeypatch): + # node_id is normally always passed (one ubridge per node); the counter + # fallback only fires when it's missing. Match the numbered pattern so the + # assertion is independent of class-counter ordering across the suite. + hyp = _make("unix", tmp_path, monkeypatch, node_id=None) + assert re.search(r"ubridge-\d+\.sock$", hyp._socket_path) + + +def test_init_tcp_sets_host_port(tmp_path, monkeypatch): + + hyp = _make("tcp", tmp_path, monkeypatch) + assert hyp._socket_path is None + assert hyp._host == "127.0.0.1" + assert isinstance(hyp._port, int) and hyp._port > 0 + + +# --------------------------------------------------------------------------- +# _build_command + endpoint +# --------------------------------------------------------------------------- + +def test_build_command_unix(tmp_path, monkeypatch): + + hyp = _make("unix", tmp_path, monkeypatch) + cmd = hyp._build_command() + assert cmd[0] == "ubridge" + assert "-U" in cmd + assert hyp._socket_path in cmd + assert "-H" not in cmd + assert "-d" not in cmd # debug flag only at DEBUG level + + +def test_build_command_tcp(tmp_path, monkeypatch): + + hyp = _make("tcp", tmp_path, monkeypatch) + cmd = hyp._build_command() + assert cmd[0] == "ubridge" + assert "-H" in cmd + assert f"{hyp._host}:{hyp._port}" in cmd + assert "-U" not in cmd + + +def test_build_command_debug_flag(tmp_path, monkeypatch): + + hyp = _make("unix", tmp_path, monkeypatch) + logger = logging.getLogger("gns3server.compute.ubridge.hypervisor") + original = logger.level + logger.setLevel(logging.DEBUG) + try: + cmd = hyp._build_command() + assert "-d" in cmd and "1" in cmd + finally: + logger.setLevel(original) + + +def test_endpoint_unix(tmp_path, monkeypatch): + + hyp = _make("unix", tmp_path, monkeypatch) + assert hyp.endpoint == hyp._socket_path + + +def test_endpoint_tcp(tmp_path, monkeypatch): + + hyp = _make("tcp", tmp_path, monkeypatch) + assert hyp.endpoint == f"{hyp._host}:{hyp._port}" + + +# --------------------------------------------------------------------------- +# stop: AF_UNIX socket cleanup +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_stop_unlinks_unix_socket(tmp_path, monkeypatch): + + hyp = _make("unix", tmp_path, monkeypatch) + # Simulate the socket file ubridge would have created. + open(hyp._socket_path, "w").close() + # Stopped process => is_running() is False => skips UBridgeHypervisor.stop (no send). + hyp._process = MagicMock() + hyp._process.returncode = 0 + assert os.path.exists(hyp._socket_path) + + await hyp.stop() + + assert not os.path.exists(hyp._socket_path) + + +@pytest.mark.asyncio +async def test_stop_tcp_has_no_socket_to_unlink(tmp_path, monkeypatch): + # TCP transport: no socket_path, so stop must simply not raise. + hyp = _make("tcp", tmp_path, monkeypatch) + hyp._process = MagicMock() + hyp._process.returncode = 0 + await hyp.stop() + + +# --------------------------------------------------------------------------- +# start: fail-fast on an immediately-exiting uBridge +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_start_detects_immediate_exit(tmp_path, monkeypatch): + # An unsupported flag (e.g. -U on an old ubridge) makes the process exit at + # once. start() must surface that from ubridge.log instead of timing out in + # connect() with a confusing "couldn't connect" error. + hyp = _make("unix", tmp_path, monkeypatch) + proc = MagicMock() + proc.pid = 1234 + proc.returncode = 2 # already exited + with patch.object(Hypervisor, "_check_ubridge_version", new_callable=AsyncMock), \ + patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=proc): + with pytest.raises(UbridgeError, match="exited immediately"): + await hyp.start() + + +@pytest.mark.asyncio +async def test_start_proceeds_when_process_keeps_running(tmp_path, monkeypatch): + # Healthy startup: the process stays up, so start() returns normally. + hyp = _make("unix", tmp_path, monkeypatch) + proc = MagicMock() + proc.pid = 1234 + proc.returncode = None # still running + with patch.object(Hypervisor, "_check_ubridge_version", new_callable=AsyncMock), \ + patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=proc): + await hyp.start() # must NOT raise + assert hyp._process is proc diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py index 6348a7a48..d85f57121 100644 --- a/tests/controller/test_marker.py +++ b/tests/controller/test_marker.py @@ -378,3 +378,86 @@ async def test_markers_aggregation(project): assert agg[key]["highlight_duration"] == 800 assert agg[key]["link_id"] == link.id assert agg[key]["node_id"] == agg[key]["capture_node_id"] + + +# --------------------------------------------------------------------------- +# Direction clear/preserve semantics (sentinel _UNSET vs explicit None) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_update_marker_clears_direction(project): + # Explicit direction=None clears the filter back to "both directions" — + # distinct from omitting the kwarg (which preserves the stored value). + with _valid_bpf(): + link = await _make_link(project) + await link.start_marker("m", "icmp", direction="tx") + assert link.markers["m"]["direction"] == "tx" + await link.update_marker("m", direction=None) + assert link.markers["m"]["direction"] is None + + +@pytest.mark.asyncio +async def test_update_marker_preserves_direction_when_omitted(project): + # Omitting direction entirely is a partial update: the stored value stays. + with _valid_bpf(): + link = await _make_link(project) + await link.start_marker("m", "icmp", direction="tx") + await link.update_marker("m", tag=9) + assert link.markers["m"]["direction"] == "tx" + assert link.markers["m"]["tag"] == 9 + + +@pytest.mark.asyncio +async def test_update_marker_definition_clears_direction(project): + # Clearing a definition's direction must propagate to every inherited copy. + with _valid_bpf(): + link1 = await _make_link(project) + link2 = await _make_link(project) + await project.create_marker_definition("arp", "arp", direction="tx") + for link in (link1, link2): + assert link.markers["global-arp"]["direction"] == "tx" + await project.update_marker_definition("arp", direction=None) + + assert project.marker_definitions["arp"]["direction"] is None + for link in (link1, link2): + assert link.markers["global-arp"]["direction"] is None + + +# --------------------------------------------------------------------------- +# Capture-node routing + capability validation +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_pinned_marker_routes_only_to_chosen_node(project): + # The marker rides only the pinned capture node's NIO; the far endpoint sees nothing. + with _valid_bpf(): + link = await _make_link(project) + chosen = link._nodes[1]["node"] + other = link._nodes[0]["node"] + await link.start_marker("icmp", "icmp", capture_node_id=chosen.id) + + assert "icmp" in link._markers_for_node(chosen) + assert "icmp" not in link._markers_for_node(other) + + +@pytest.mark.asyncio +async def test_markers_for_node_carries_direction(project): + # The NIO-bound marker spec forwards direction so uBridge gets the dir token. + with _valid_bpf(): + link = await _make_link(project) + node = link._nodes[0]["node"] # auto-pick selects the first capable endpoint + await link.start_marker("m", "icmp", direction="rx") + + assert link._markers_for_node(node)["m"]["direction"] == "rx" + + +@pytest.mark.asyncio +async def test_start_marker_rejects_non_capable_capture_node(project): + # A NAT endpoint has no uBridge bridge. Pinning to it must fail even though + # it IS a link endpoint (distinct from the not-an-endpoint -> 404 case). + with _valid_bpf(): + link = await _make_link(project) + nat = Node(project, link._nodes[0]["node"].compute, "nat", node_type="nat") + link._nodes.append({"node": nat, "adapter_number": 0, "port_number": 0}) + with pytest.raises(ControllerError): + await link.start_marker("m", "icmp", capture_node_id=nat.id) From 3d06c4e22f1df6a7329523b33840f30160791a4e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 2 Aug 2026 16:19:20 +0800 Subject: [PATCH 08/36] marker: drive uBridge enabled/pause/resume in real time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire gns3-server to uBridge's real-time marker controls (contract ../ubridge/doc/gns3server-integration.md §3.2), in three layers: A. enabled reaches uBridge — _markers_for_node no longer drops disabled markers controller-side; it carries `enabled` in the NIO spec. apply installs every marker then issues `enable_packet_filter … off` for the disabled ones (base_node bridge / iou iol_bridge; old-ubridge errors downgrade to a warning so a toggle can't break link create). B. instant per-filter toggle — apply records name→bridge so a new `_ubridge_set_marker_filter_state` can flip a running filter with `enable_packet_filter on|off` (iou overrides for iol_bridge + bay/unit). Each marker-capable node type gains PUT /markers/{name}; update_marker short-circuits to it when only `enabled` changes (no NIO rebuild, no pcap flush), falling back to reset+reapply if the route is unavailable. C. global pause/resume — `_ubridge_marker_pause/resume` send `marker pause` / `marker resume` direct to the hypervisor (pause stops signal+pcap, resume instant, sink retained). Six node-type routes add POST /markers/pause|resume; project.pause_all/resume_all_markers fan out to each capture node (deduped, best-effort); REST exposes POST /projects/{id}/markers/pause|resume. Toggling enabled and pause/resume are now both instant — only marker create or a bpf change still go through reset+reapply. --- gns3server/api/routes/compute/cloud_nodes.py | 34 +++++++++++ gns3server/api/routes/compute/docker_nodes.py | 37 ++++++++++++ .../api/routes/compute/dynamips_nodes.py | 37 ++++++++++++ gns3server/api/routes/compute/iou_nodes.py | 37 ++++++++++++ gns3server/api/routes/compute/qemu_nodes.py | 37 ++++++++++++ gns3server/api/routes/compute/vpcs_nodes.py | 37 ++++++++++++ gns3server/api/routes/controller/projects.py | 31 ++++++++++ gns3server/compute/base_node.py | 57 +++++++++++++++++++ gns3server/compute/iou/iou_vm.py | 16 ++++++ gns3server/controller/project.py | 35 ++++++++++++ gns3server/controller/udp_link.py | 40 +++++++++++-- gns3server/schemas/__init__.py | 2 +- gns3server/schemas/compute/nios.py | 10 ++++ 13 files changed, 404 insertions(+), 6 deletions(-) diff --git a/gns3server/api/routes/compute/cloud_nodes.py b/gns3server/api/routes/compute/cloud_nodes.py index 42e691235..539c73a44 100644 --- a/gns3server/api/routes/compute/cloud_nodes.py +++ b/gns3server/api/routes/compute/cloud_nodes.py @@ -255,3 +255,37 @@ 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_cloud_marker( + marker_name: str, + toggle_data: schemas.MarkerToggle, + node: Cloud = Depends(dep_node) +) -> dict: + """ + Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). + """ + + 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_cloud_markers(node: Cloud = 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_cloud_markers(node: Cloud = Depends(dep_node)) -> None: + + await node._ubridge_marker_resume() diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index f08f9a497..a322bd56e 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -408,3 +408,40 @@ async def vnc_console_ws( async def reset_console(node: DockerVM = Depends(dep_node)) -> None: await node.reset_console() + + +@router.put( + "/{node_id}/markers/{marker_name}", + dependencies=[Depends(compute_authentication)] +) +async def toggle_docker_marker( + marker_name: str, + toggle_data: schemas.MarkerToggle, + node: DockerVM = Depends(dep_node) +) -> dict: + """ + Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). + """ + + 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, + dependencies=[Depends(compute_authentication)] +) +async def pause_docker_markers(node: DockerVM = Depends(dep_node)) -> None: + + await node._ubridge_marker_pause() + + +@router.post( + "/{node_id}/markers/resume", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def resume_docker_markers(node: DockerVM = Depends(dep_node)) -> None: + + await node._ubridge_marker_resume() diff --git a/gns3server/api/routes/compute/dynamips_nodes.py b/gns3server/api/routes/compute/dynamips_nodes.py index 70d2b10c6..05c32e7a6 100644 --- a/gns3server/api/routes/compute/dynamips_nodes.py +++ b/gns3server/api/routes/compute/dynamips_nodes.py @@ -367,3 +367,40 @@ async def console_ws( async def reset_console(node: Router = Depends(dep_node)) -> None: await node.reset_console() + + +@router.put( + "/{node_id}/markers/{marker_name}", + dependencies=[Depends(compute_authentication)] +) +async def toggle_dynamips_marker( + marker_name: str, + toggle_data: schemas.MarkerToggle, + node: Router = Depends(dep_node) +) -> dict: + """ + Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). + """ + + 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, + dependencies=[Depends(compute_authentication)] +) +async def pause_dynamips_markers(node: Router = Depends(dep_node)) -> None: + + await node._ubridge_marker_pause() + + +@router.post( + "/{node_id}/markers/resume", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def resume_dynamips_markers(node: Router = Depends(dep_node)) -> None: + + await node._ubridge_marker_resume() diff --git a/gns3server/api/routes/compute/iou_nodes.py b/gns3server/api/routes/compute/iou_nodes.py index 99544043d..e1c97819d 100644 --- a/gns3server/api/routes/compute/iou_nodes.py +++ b/gns3server/api/routes/compute/iou_nodes.py @@ -346,3 +346,40 @@ async def console_ws( async def reset_console(node: IOUVM = Depends(dep_node)) -> None: await node.reset_console() + + +@router.put( + "/{node_id}/markers/{marker_name}", + dependencies=[Depends(compute_authentication)] +) +async def toggle_iou_marker( + marker_name: str, + toggle_data: schemas.MarkerToggle, + node: IOUVM = Depends(dep_node) +) -> dict: + """ + Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). + """ + + 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, + dependencies=[Depends(compute_authentication)] +) +async def pause_iou_markers(node: IOUVM = Depends(dep_node)) -> None: + + await node._ubridge_marker_pause() + + +@router.post( + "/{node_id}/markers/resume", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def resume_iou_markers(node: IOUVM = Depends(dep_node)) -> None: + + await node._ubridge_marker_resume() diff --git a/gns3server/api/routes/compute/qemu_nodes.py b/gns3server/api/routes/compute/qemu_nodes.py index 6582dc731..7dc93869d 100644 --- a/gns3server/api/routes/compute/qemu_nodes.py +++ b/gns3server/api/routes/compute/qemu_nodes.py @@ -438,3 +438,40 @@ async def vnc_console_ws( async def reset_console(node: QemuVM = Depends(dep_node)) -> None: await node.reset_console() + + +@router.put( + "/{node_id}/markers/{marker_name}", + dependencies=[Depends(compute_authentication)] +) +async def toggle_qemu_marker( + marker_name: str, + toggle_data: schemas.MarkerToggle, + node: QemuVM = Depends(dep_node) +) -> dict: + """ + Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). + """ + + 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, + dependencies=[Depends(compute_authentication)] +) +async def pause_qemu_markers(node: QemuVM = Depends(dep_node)) -> None: + + await node._ubridge_marker_pause() + + +@router.post( + "/{node_id}/markers/resume", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def resume_qemu_markers(node: QemuVM = Depends(dep_node)) -> None: + + await node._ubridge_marker_resume() diff --git a/gns3server/api/routes/compute/vpcs_nodes.py b/gns3server/api/routes/compute/vpcs_nodes.py index 07c439972..fd478833a 100644 --- a/gns3server/api/routes/compute/vpcs_nodes.py +++ b/gns3server/api/routes/compute/vpcs_nodes.py @@ -345,3 +345,40 @@ async def console_ws( async def reset_console(node: VPCSVM = Depends(dep_node)) -> None: await node.reset_console() + + +@router.put( + "/{node_id}/markers/{marker_name}", + dependencies=[Depends(compute_authentication)] +) +async def toggle_vpcs_marker( + marker_name: str, + toggle_data: schemas.MarkerToggle, + node: VPCSVM = Depends(dep_node) +) -> dict: + """ + Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). + """ + + 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, + dependencies=[Depends(compute_authentication)] +) +async def pause_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None: + + await node._ubridge_marker_pause() + + +@router.post( + "/{node_id}/markers/resume", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def resume_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None: + + await node._ubridge_marker_resume() diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index 33ad90902..107392bfa 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -219,6 +219,37 @@ def get_project_markers(project: Project = Depends(dep_project)) -> dict: return project.markers +@router.post( + "/{project_id}/markers/pause", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(has_privilege("Project.Modify"))] +) +async def pause_project_markers(project: Project = Depends(dep_project)) -> None: + """ + Pause marker signal+pcap emission project-wide (``marker pause`` on every + marker-hosting node's uBridge; resume is instant, sink retained). + + Required privilege: Project.Modify + """ + + await project.pause_all_markers() + + +@router.post( + "/{project_id}/markers/resume", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(has_privilege("Project.Modify"))] +) +async def resume_project_markers(project: Project = Depends(dep_project)) -> None: + """ + Resume marker signal+pcap emission project-wide. + + Required privilege: Project.Modify + """ + + await project.resume_all_markers() + + # --------------------------------------------------------------------------- # Project-level marker definitions (global rules inherited by every link) # --------------------------------------------------------------------------- diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 9b9ede270..e1f9fa591 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -100,6 +100,9 @@ class BaseNode: self._internal_aux_port = None self._custom_adapters = [] self._ubridge_require_privileged_access = False + # marker filter name -> uBridge bridge_name (recorded at apply time so + # _ubridge_set_marker_filter_state can toggle on/off without an NIO rebuild). + self._marker_filter_bridges = {} if self._console is not None: # use a previously allocated console port @@ -1163,9 +1166,63 @@ class BaseNode: self.project.emit("log.warning", {"message": message}) continue raise + # A disabled marker is installed but turned off (a paused tap), not + # dropped — so the UI can flip it back on instantly with + # enable_packet_filter, no NIO rebuild (ubridge contract §3.2). + if not spec.get("enabled", True): + try: + await self._ubridge_send(f"bridge enable_packet_filter {bridge_name} {name} off") + except UbridgeError as e: + # Old ubridge without enable_packet_filter: leave it installed + # (on) rather than fail the whole link/marker apply. + log.warning(f"Could not turn marker '{name}' off on {bridge_name}: {e}") manager.register( str(self.project.id), self._id, name, link_id, tag ) + # Remember which bridge hosts this filter so an instant on/off toggle + # (no NIO rebuild) can resolve it by name alone. + self._marker_filter_bridges[name] = bridge_name + + async def _ubridge_set_marker_filter_state(self, name, enabled): + """ + Toggle an installed marker filter on/off with a single uBridge command + (``bridge enable_packet_filter … on|off``) — no NIO reset/reapply, so the + pcap identity and emitted counter are preserved (ubridge contract §3.2). + The bridge is resolved from the name→bridge map populated at apply time; + IOU overrides this for its ``iol_bridge`` command shape. + + :param name: marker filter name + :param enabled: True = on (signal+pcap), False = off (paused tap) + """ + + bridge_name = self._marker_filter_bridges.get(name) + if not bridge_name: + raise UbridgeError(f"Marker '{name}' is not installed on this node") + state = "on" if enabled else "off" + await self._ubridge_send(f"bridge enable_packet_filter {bridge_name} {name} {state}") + + async def _ubridge_marker_pause(self): + """ + Pause all marker signal+pcap emission on this node's uBridge + (``marker pause``). Keeps the sink open so ``resume`` is instant. Safe + on old ubridge builds (the error is downgraded to a warning). Called by + the project-level pause fan-out. + """ + + if self._ubridge_hypervisor: + try: + await self._ubridge_hypervisor.send("marker pause") + except UbridgeError as e: + log.warning(f"Could not pause markers on node {self._id}: {e}") + + async def _ubridge_marker_resume(self): + """Resume marker signal+pcap emission (``marker resume``).""" + + if self._ubridge_hypervisor: + try: + await self._ubridge_hypervisor.send("marker resume") + except UbridgeError as e: + log.warning(f"Could not resume markers on node {self._id}: {e}") async def _add_ubridge_ethernet_connection(self, bridge_name, ethernet_interface, block_host_traffic=False): """ diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index f2936f044..33a4e6c4c 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -1321,9 +1321,25 @@ class IOUVM(BaseNode): self.project.emit("log.warning", {"message": message}) continue raise + if not spec.get("enabled", True): + try: + await self._ubridge_send(f"iol_bridge enable_packet_filter {location} {name} off") + except UbridgeError as e: + log.warning(f"Could not turn marker '{name}' off on {location}: {e}") manager.register( str(self.project.id), self._id, name, link_id, tag ) + # Record name -> location (bridge bay unit) for instant toggle. + self._marker_filter_bridges[name] = location + + async def _ubridge_set_marker_filter_state(self, name, enabled): + """IOU override: toggle via ``iol_bridge enable_packet_filter on|off``.""" + + location = self._marker_filter_bridges.get(name) + if not location: + raise UbridgeError(f"Marker '{name}' is not installed on this node") + state = "on" if enabled else "off" + await self._ubridge_send(f"iol_bridge enable_packet_filter {location} {name} {state}") async def adapter_remove_nio_binding(self, adapter_number, port_number): """ diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index aa1cbd82d..7c68f3eed 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -924,6 +924,41 @@ class Project: } return result + async def pause_all_markers(self): + """ + Pause marker signal+pcap emission on every node hosting a marker + (``marker pause`` per capture node's uBridge). Node-deduplicated and + best-effort: a node hosting markers on several links is paused once, + and a node that is down or running an old compute is skipped. + """ + + seen = set() + for link in list(self._links.values()): + for info in link.markers.values(): + node_id = info.get("capture_node_id") + if not node_id or node_id in seen: + continue + seen.add(node_id) + try: + await self.get_node(node_id).post("/markers/pause") + except Exception: + pass + + async def resume_all_markers(self): + """Resume marker signal+pcap emission on every marker-hosting node.""" + + seen = set() + for link in list(self._links.values()): + for info in link.markers.values(): + node_id = info.get("capture_node_id") + if not node_id or node_id in seen: + continue + seen.add(node_id) + try: + await self.get_node(node_id).post("/markers/resume") + except Exception: + pass + @property def marker_definitions(self): """ diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index e3764e317..b71bdf291 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -57,14 +57,17 @@ class UDPLink(Link): def _markers_for_node(self, node): """ - Marker specs (name -> {bpf, tag, link_id}) for the markers whose capture - side is ``node`` and that are enabled. Routed by capture_node_id so a - marker only rides the NIO of the node whose uBridge will host it. + Marker specs (name -> {bpf, tag, link_id, direction, enabled}) for the + markers whose capture side is ``node``. Routed by capture_node_id so a + marker only rides the NIO of the node whose uBridge will host it. A + disabled marker is included (installed then turned ``off`` at uBridge, + not dropped) so the UI can toggle it instantly without an NIO rebuild. """ return { - name: {"bpf": m["bpf"], "tag": m.get("tag"), "link_id": self._id, "direction": m.get("direction")} + name: {"bpf": m["bpf"], "tag": m.get("tag"), "link_id": self._id, + "direction": m.get("direction"), "enabled": m.get("enabled", True)} for name, m in self._markers.items() - if m.get("enabled", True) and m.get("capture_node_id") == node.id + if m.get("capture_node_id") == node.id } def _get_node_markers(self, node1, node2): @@ -457,6 +460,33 @@ class UDPLink(Link): "Update it via the marker-definitions API instead." ) + # Instant toggle: when only `enabled` changes, send a single + # enable_packet_filter on|off to the capture node instead of rebuilding + # the whole NIO (no pcap flush, emitted counter preserved). Falls through + # to the full reset+reapply below if the compute route is unavailable. + only_enabled = ( + enabled is not None + and bpf is None + and tag is None + and direction is _UNSET + and color is None + and highlight_duration is None + ) + if only_enabled and self._created and not marker_info.get("inherited_from"): + capture_node_id = marker_info.get("capture_node_id") + side = next((s for s in self._nodes if str(s["node"].id) == str(capture_node_id)), None) + if side is not None: + try: + await side["node"].put(f"/markers/{name}", data={"enabled": enabled}) + marker_info["enabled"] = enabled + self._project.emit_notification("link.updated", self.asdict()) + self._project.dump() + return + except Exception: + # Old compute without the toggle route / node down: fall + # through to the full NIO reset+reapply below. + pass + if bpf is not None and bpf != marker_info["bpf"]: result = validate_bpf_syntax(bpf) if not result.get("valid"): diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index 7519639bc..7c8b9cab2 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -91,7 +91,7 @@ from .controller.templates.dynamips_templates import ( ) # Compute schemas -from .compute.nios import UDPNIO, TAPNIO, EthernetNIO +from .compute.nios import UDPNIO, TAPNIO, EthernetNIO, MarkerToggle from .compute.atm_switch_nodes import ATMSwitchCreate, ATMSwitchUpdate, ATMSwitch from .compute.cloud_nodes import CloudCreate, CloudUpdate, Cloud from .compute.docker_nodes import DockerCreate, DockerUpdate, Docker diff --git a/gns3server/schemas/compute/nios.py b/gns3server/schemas/compute/nios.py index f5cf5f073..f9a7ce7e6 100644 --- a/gns3server/schemas/compute/nios.py +++ b/gns3server/schemas/compute/nios.py @@ -65,3 +65,13 @@ class TAPNIO(BaseModel): type: TAPNIOType tap_device: str = Field(..., description="TAP device name e.g. tap0") + + +class MarkerToggle(BaseModel): + """ + Body for the per-marker enable/disable toggle endpoint: flips a running + uBridge marker filter with ``enable_packet_filter on|off`` (no NIO rebuild, + so the pcap identity and emitted counter are preserved). + """ + + enabled: bool From 84179c239c1720c07ccc2b713c99135be4867fd5 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 2 Aug 2026 16:19:20 +0800 Subject: [PATCH 09/36] marker: test enabled/pause/resume and instant toggle - compute (test_base_node.py): set_marker_filter_state on/off command, marker pause/resume command, and apply issues enable_packet_filter off for a disabled marker (+ records the name->bridge map). - controller (test_marker.py): _markers_for_node keeps disabled markers and carries enabled; update_marker enabled-only hits the toggle route (not NIO rebuild) while a bpf change still rebuilds; pause/resume fan out to capture nodes. --- tests/compute/test_base_node.py | 55 +++++++++++++++++++++++ tests/controller/test_marker.py | 77 +++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/tests/compute/test_base_node.py b/tests/compute/test_base_node.py index 58c06ff5c..cee8644f3 100644 --- a/tests/compute/test_base_node.py +++ b/tests/compute/test_base_node.py @@ -21,6 +21,7 @@ import pytest import pytest_asyncio from tests.utils import asyncio_patch, AsyncioMagicMock +from unittest.mock import patch, MagicMock from gns3server.compute.vpcs.vpcs_vm import VPCSVM from gns3server.compute.docker.docker_vm import DockerVM @@ -172,3 +173,57 @@ async def test_ubridge_apply_bpf_filters(node): node._ubridge_send.assert_any_call("bridge reset_packet_filters VPCS-10") node._ubridge_send.assert_any_call("bridge add_packet_filter VPCS-10 filter0 bpf \"icmp[icmptype] == 8\"") node._ubridge_send.assert_any_call("bridge add_packet_filter VPCS-10 filter1 bpf \"tcp src port 53\"") + + +@pytest.mark.asyncio +async def test_set_marker_filter_state_off(compute_project, manager): + + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + node._ubridge_send = AsyncioMagicMock() + node._marker_filter_bridges["m"] = "VPCS-10" + await node._ubridge_set_marker_filter_state("m", False) + node._ubridge_send.assert_called_with("bridge enable_packet_filter VPCS-10 m off") + + +@pytest.mark.asyncio +async def test_set_marker_filter_state_on(compute_project, manager): + + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + node._ubridge_send = AsyncioMagicMock() + node._marker_filter_bridges["m"] = "VPCS-10" + await node._ubridge_set_marker_filter_state("m", True) + node._ubridge_send.assert_called_with("bridge enable_packet_filter VPCS-10 m on") + + +@pytest.mark.asyncio +async def test_marker_pause_sends_command(compute_project, manager): + + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + node._ubridge_hypervisor = AsyncioMagicMock() + await node._ubridge_marker_pause() + node._ubridge_hypervisor.send.assert_called_with("marker pause") + + +@pytest.mark.asyncio +async def test_marker_resume_sends_command(compute_project, manager): + + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + node._ubridge_hypervisor = AsyncioMagicMock() + await node._ubridge_marker_resume() + node._ubridge_hypervisor.send.assert_called_with("marker resume") + + +@pytest.mark.asyncio +async def test_apply_markers_turns_disabled_filter_off(compute_project, manager): + # Part A: a disabled marker is installed (add_packet_filter) then turned off + # with enable_packet_filter … off, and its bridge is recorded for toggling. + + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + node._ubridge_send = AsyncioMagicMock() + nio = NIOUDP(1234, "127.0.0.1", 4321) + nio.markers = {"m": {"bpf": "icmp", "tag": None, "link_id": "L1", "direction": None, "enabled": False}} + with patch("gns3server.compute.marker.marker_manager.MarkerManager") as mm: + mm.instance.return_value.register = MagicMock() + await node._ubridge_apply_markers("VPCS-10", nio) + node._ubridge_send.assert_any_call("bridge enable_packet_filter VPCS-10 m off") + assert node._marker_filter_bridges["m"] == "VPCS-10" diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py index d85f57121..e78b3b60d 100644 --- a/tests/controller/test_marker.py +++ b/tests/controller/test_marker.py @@ -461,3 +461,80 @@ async def test_start_marker_rejects_non_capable_capture_node(project): link._nodes.append({"node": nat, "adapter_number": 0, "port_number": 0}) with pytest.raises(ControllerError): await link.start_marker("m", "icmp", capture_node_id=nat.id) + + +# --------------------------------------------------------------------------- +# Part A/B: enabled reaches uBridge + instant per-filter toggle +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_markers_for_node_keeps_disabled_and_carries_enabled(project): + # Part A: a disabled marker is NOT dropped from the NIO payload (so uBridge + # can install it then turn it off) and the spec carries `enabled`. + with _valid_bpf(): + link = await _make_link(project) + node = link._nodes[0]["node"] + await link.start_marker("m", "icmp") + await link.update_marker("m", enabled=False) + spec = link._markers_for_node(node).get("m") + assert spec is not None + assert spec["enabled"] is False + + +@pytest.mark.asyncio +async def test_update_marker_enabled_only_hits_toggle_route(project): + # Part B: an enabled-only change routes to the per-marker toggle endpoint, + # not a full NIO reset+reapply. + with _valid_bpf(): + link = await _make_link(project) + node = link._nodes[0]["node"] + await link.start_marker("m", "icmp") + compute = node.compute + compute.put.reset_mock() + await link.update_marker("m", enabled=False) + paths = [c.args[0] for c in compute.put.call_args_list] + assert any("/markers/m" in p for p in paths) + assert not any(p.endswith("/nio") for p in paths) + + +@pytest.mark.asyncio +async def test_update_marker_with_bpf_still_rebuilds_nio(project): + # A non-enabled-only change falls through to the NIO reset+reapply path. + with _valid_bpf(): + link = await _make_link(project) + node = link._nodes[0]["node"] + await link.start_marker("m", "icmp") + compute = node.compute + compute.put.reset_mock() + await link.update_marker("m", bpf="tcp") + paths = [c.args[0] for c in compute.put.call_args_list] + assert any(p.endswith("/nio") for p in paths) + + +# --------------------------------------------------------------------------- +# Part C: project-level pause/resume fan-out +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_pause_all_markers_fans_out_to_capture_nodes(project): + # Part C: project-level pause hits each marker-hosting node once. + with _valid_bpf(): + link = await _make_link(project) + await link.start_marker("m", "icmp") + node = link._nodes[0]["node"] + node.post = AsyncioMagicMock() + project.get_node = MagicMock(return_value=node) + await project.pause_all_markers() + node.post.assert_any_call("/markers/pause") + + +@pytest.mark.asyncio +async def test_resume_all_markers_fans_out(project): + with _valid_bpf(): + link = await _make_link(project) + await link.start_marker("m", "icmp") + node = link._nodes[0]["node"] + node.post = AsyncioMagicMock() + project.get_node = MagicMock(return_value=node) + await project.resume_all_markers() + node.post.assert_any_call("/markers/resume") From 8ba950fa29cd60a835cea85361e1f3658628a5fe Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 2 Aug 2026 16:22:31 +0800 Subject: [PATCH 10/36] marker: document pause/resume and instant enabled toggle --- docs/features/marker-traffic-insight.md | 29 ++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/features/marker-traffic-insight.md b/docs/features/marker-traffic-insight.md index a91091bf4..5e4e51ea8 100644 --- a/docs/features/marker-traffic-insight.md +++ b/docs/features/marker-traffic-insight.md @@ -147,6 +147,31 @@ observer would silently flip the meaning of stored `direction`, so recreate the instead). It is not accepted on project-level definitions — a definition is link-agnostic and has no endpoints to choose from, so inherited markers always auto-pick per link. +## Pause & resume + +Two independent ways to silence marker activity, both instant and without an +NIO rebuild or pcap flush: + +- **Per-filter toggle** — `PUT /v3/projects/{pid}/links/{lid}/markers/{name}` + with `{"enabled": false}` flips the filter off in place (uBridge + `enable_packet_filter … off`): no signal, no pcap, but traffic still relays — + a paused `mark` is a no-op tap, not a drop. `{"enabled": true}` flips it back. + A change to `enabled` alone is a single command (the pcap identity and emitted + counter are preserved); changing `bpf` or other fields still goes through a + reset+reapply. +- **Project-wide mute** — `POST /v3/projects/{pid}/markers/pause` and `/resume` + issue uBridge `marker pause` / `marker resume` on every capture node. Pause + stops signal **and** pcap but keeps the sink open, so resume is instant. Use + for a global "mute all markers" button. + +The two levers compose and do not overlap: + +| Action | signal | pcap | sink | +|--------|--------|------|------| +| per-filter `enabled: false` | stop | stop | n/a | +| `marker pause` (project) | stop | stop | kept (resume instant) | +| `marker resume` (project) | resume | resume | kept | + ## API Endpoints All endpoints require a JWT bearer token (`POST /v3/access/users/authenticate`). The @@ -175,6 +200,8 @@ All endpoints require a JWT bearer token (`POST /v3/access/users/authenticate`). | Method | Path | Description | Auth | |--------|------|-------------|------| | GET | `/v3/projects/{pid}/markers` | All markers across links, flat | Project.Audit | +| POST | `/v3/projects/{pid}/markers/pause` | Mute all markers project-wide (signal+pcap) | Project.Modify | +| POST | `/v3/projects/{pid}/markers/resume` | Resume all markers project-wide | Project.Modify | The link object returned by `GET /v3/projects/{pid}/links[/{lid}]` also carries a `markers` field (including inherited markers), so the Web UI can render a link's markers without an @@ -248,7 +275,7 @@ extra request. |-------|------|-------------| | `bpf` | string | libpcap BPF expression (required) | | `tag` | int \| null | Correlation id echoed in `MARK` signals | -| `enabled` | bool | Whether the marker is active | +| `enabled` | bool | Whether the marker is active. Toggle is instant: `false` flips the uBridge filter off in place (no signal/pcap), `true` back on — no NIO rebuild (see [Pause & resume](#pause--resume)) | | `color` | string \| null | Hex color render hint, e.g. `#ff5722` | | `highlight_duration` | int \| null | UI highlight duration in ms after a match; `null` = UI default | | `direction` | string \| null | `tx` / `rx` filter relative to the capture node; `null` = both | From f7d7ba165a81f335d3f72caf9d3fe27092e03659 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 2 Aug 2026 22:21:05 +0800 Subject: [PATCH 11/36] marker: persist project-wide markers_paused to the .gns3 file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pause/resume was fire-and-forget: the controller sent marker pause/resume but stored nothing, so the Web UI could only keep a local optimistic flag that was lost on panel reopen. Treat the mute as a project-level config (like per-marker enabled): record _markers_paused, persist it in the topology (asdict + load), and echo it on the project object so the UI renders from server truth. Because marker pause is a uBridge runtime flag that resets on node restart, start_all re-applies the mute to freshly started uBridges after a project reopen — a paused project stays paused across close/reopen. --- docs/features/marker-traffic-insight.md | 8 ++++++++ gns3server/controller/project.py | 12 ++++++++++++ tests/controller/test_project.py | 1 + 3 files changed, 21 insertions(+) diff --git a/docs/features/marker-traffic-insight.md b/docs/features/marker-traffic-insight.md index 5e4e51ea8..3287d4d20 100644 --- a/docs/features/marker-traffic-insight.md +++ b/docs/features/marker-traffic-insight.md @@ -172,6 +172,14 @@ The two levers compose and do not overlap: | `marker pause` (project) | stop | stop | kept (resume instant) | | `marker resume` (project) | resume | resume | kept | +The project-wide pause state is **persisted** in the `.gns3` file as +`markers_paused` and echoed on the project object (`GET /v3/projects/{pid}`, +the `asdict()` body), so the Web UI renders the mute button from server truth +rather than a local optimistic flag. Because `marker pause` is a uBridge +runtime flag that resets when a node restarts, `start_all` re-applies the mute +to freshly started uBridges after a project reopen — so a paused project stays +paused across close/reopen. + ## API Endpoints All endpoints require a JWT bearer token (`POST /v3/access/users/authenticate`). The diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 7c68f3eed..371167883 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -214,6 +214,7 @@ class Project: self._nodes = {} self._links = {} self._marker_definitions = {} # name → {bpf, tag, color, highlight_duration} + self._markers_paused = False # project-wide marker mute (persisted, see asdict) self._drawings = {} self._snapshots = {} self._computes = [] @@ -932,6 +933,7 @@ class Project: and a node that is down or running an old compute is skipped. """ + self._markers_paused = True seen = set() for link in list(self._links.values()): for info in link.markers.values(): @@ -943,10 +945,12 @@ class Project: await self.get_node(node_id).post("/markers/pause") except Exception: pass + self.dump() async def resume_all_markers(self): """Resume marker signal+pcap emission on every marker-hosting node.""" + self._markers_paused = False seen = set() for link in list(self._links.values()): for info in link.markers.values(): @@ -958,6 +962,7 @@ class Project: await self.get_node(node_id).post("/markers/resume") except Exception: pass + self.dump() @property def marker_definitions(self): @@ -1472,6 +1477,7 @@ class Project: defs = project_data.get("marker_definitions") if isinstance(defs, dict): self._marker_definitions = defs + self._markers_paused = bool(project_data.get("markers_paused", False)) topology = project_data["topology"] for compute in topology.get("computes", []): @@ -1810,6 +1816,11 @@ class Project: if not node.is_always_running(): pool.append(node.start) await pool.join() + # marker pause is a uBridge runtime flag that resets when a node + # restarts, so re-apply the project-wide mute to the freshly started + # uBridges (markers are installed during node start). + if self._markers_paused: + await self.pause_all_markers() @open_required async def stop_all(self): @@ -1925,6 +1936,7 @@ class Project: "variables": self._variables, "created_by": self._created_by, "marker_definitions": self._marker_definitions, + "markers_paused": self._markers_paused, } def __repr__(self): diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py index b0202c01f..570fa7296 100644 --- a/tests/controller/test_project.py +++ b/tests/controller/test_project.py @@ -83,6 +83,7 @@ async def test_json(): "supplier": None, "variables": None, "marker_definitions": {}, + "markers_paused": False, "created_by": None } From e97df86d962a4f4fcfab8ce1c7c0f0a4caa07ac0 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 2 Aug 2026 22:32:04 +0800 Subject: [PATCH 12/36] marker: fix PUT marker with an enabled-only body (bpf no longer required) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_marker reused MarkerCreate, whose bpf is required, so a partial PUT like {"enabled": false} was rejected with 422 "bpf field required". Add a MarkerUpdate schema with every field optional (bpf included; capture_node_id and name are create-only/path-driven and omitted) and use it for the PUT route — partial updates now validate cleanly. --- gns3server/api/routes/controller/links.py | 2 +- gns3server/schemas/__init__.py | 2 +- gns3server/schemas/controller/links.py | 23 +++++++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index 1bddff2d1..522f692cc 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -497,7 +497,7 @@ async def delete_marker( ) async def update_marker( marker_name: str, - marker_data: schemas.MarkerCreate, + marker_data: schemas.MarkerUpdate, link: Link = Depends(dep_link) ) -> dict: """ diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index 7c8b9cab2..fc18b73af 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -20,7 +20,7 @@ from .common import ErrorMessage from .version import Version # Controller schemas -from .controller.links import LinkCreate, LinkUpdate, Link, UDPPortInfo, EthernetPortInfo, LinkCapture, MarkerCreate, MarkerDefinitionCreate +from .controller.links import LinkCreate, LinkUpdate, Link, UDPPortInfo, EthernetPortInfo, LinkCapture, MarkerCreate, MarkerUpdate, MarkerDefinitionCreate from .controller.computes import ComputeCreate, ComputeUpdate, ComputeVirtualBoxVM, ComputeVMwareVM, ComputeDockerImage, AutoIdlePC, Compute from .controller.templates import TemplateCreate, TemplateUpdate, TemplateUsage, Template from .controller.images import Image, ImageType diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 3ec06c5d5..31bdc607a 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -191,6 +191,29 @@ class MarkerCreate(BaseModel): ) +class MarkerUpdate(BaseModel): + """ + Body for updating a marker — partial update, every field optional. + + ``bpf`` is optional here (it is required on create). ``capture_node_id`` and + ``name`` are create-only / path-driven and intentionally absent; an explicit + ``direction: null`` clears the direction back to both (omitting keeps it). + """ + + bpf: Optional[str] = None + tag: Optional[int] = None + direction: Optional[str] = Field( + None, + pattern=r"^(tx|rx)$", + description="Direction filter; an explicit null clears it to both. Omit to keep.", + ) + color: Optional[str] = Field(None, description="Hex color render hint, e.g. '#ff5722'") + highlight_duration: Optional[int] = Field( + None, ge=1, description="UI highlight duration in ms; null = UI default" + ) + enabled: Optional[bool] = Field(None, description="Toggle the marker on/off (instant).") + + class MarkerDefinitionCreate(BaseModel): """ Body for creating / updating a project-level marker definition. From 1eeee024bda937bba6ed7c819847615e81abc7cc Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 2 Aug 2026 22:46:33 +0800 Subject: [PATCH 13/36] marker: rework pause/resume from project-wide to per-definition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project-wide mute (POST /markers/pause|resume + _markers_paused) paused every marker with one button. The actual need is per-rule control: pause one definition and toggle only its inherited global-{name} copies across all links. - Drop project-level: _markers_paused (init/asdict/load/start_all), the pause_all/resume_all_markers methods, and the /markers/pause|resume routes. - Add per-definition: a persisted `paused` flag on each definition; pause/resume_marker_definition fan out update_marker(enabled) to every global-{name} copy — instant, via the existing enable_packet_filter toggle (no NIO rebuild, pcap/emitted preserved). New links inherit a paused definition already off (inherit_marker passes enabled=not paused). - start_marker takes an enabled kwarg; update_marker's enabled-only short-circuit now also covers inherited copies so def pause/resume is instant. - Routes: POST /marker-definitions/{name}/pause|resume. - Docs + tests updated. --- docs/features/marker-traffic-insight.md | 42 ++++++------ gns3server/api/routes/controller/projects.py | 69 +++++++++++--------- gns3server/controller/link.py | 3 +- gns3server/controller/project.py | 61 +++++++---------- gns3server/controller/udp_link.py | 6 +- tests/controller/test_marker.py | 45 ++++++++----- tests/controller/test_project.py | 1 - 7 files changed, 113 insertions(+), 114 deletions(-) diff --git a/docs/features/marker-traffic-insight.md b/docs/features/marker-traffic-insight.md index 3287d4d20..3ca90e891 100644 --- a/docs/features/marker-traffic-insight.md +++ b/docs/features/marker-traffic-insight.md @@ -149,36 +149,28 @@ has no endpoints to choose from, so inherited markers always auto-pick per link. ## Pause & resume -Two independent ways to silence marker activity, both instant and without an -NIO rebuild or pcap flush: +Two levels of silencing, both instant (no NIO rebuild, no pcap flush): -- **Per-filter toggle** — `PUT /v3/projects/{pid}/links/{lid}/markers/{name}` - with `{"enabled": false}` flips the filter off in place (uBridge +- **Per-marker (private)** — `PUT /v3/projects/{pid}/links/{lid}/markers/{name}` + with `{"enabled": false}` flips that one filter off in place (uBridge `enable_packet_filter … off`): no signal, no pcap, but traffic still relays — a paused `mark` is a no-op tap, not a drop. `{"enabled": true}` flips it back. A change to `enabled` alone is a single command (the pcap identity and emitted counter are preserved); changing `bpf` or other fields still goes through a reset+reapply. -- **Project-wide mute** — `POST /v3/projects/{pid}/markers/pause` and `/resume` - issue uBridge `marker pause` / `marker resume` on every capture node. Pause - stops signal **and** pcap but keeps the sink open, so resume is instant. Use - for a global "mute all markers" button. - -The two levers compose and do not overlap: +- **Per-definition (inherited)** — `POST /v3/projects/{pid}/marker-definitions/{name}/pause` + and `/resume` toggle **every** inherited `global-{name}` copy across all links + at once (same `enable_packet_filter on|off`, fanned out per copy). Use to + pause or resume a whole rule independently of the others. The definition's + `paused` flag is persisted to the `.gns3` and echoed on the definition object, + so links created later inherit it already paused, and the Web UI renders the + per-rule button from server truth. | Action | signal | pcap | sink | |--------|--------|------|------| -| per-filter `enabled: false` | stop | stop | n/a | -| `marker pause` (project) | stop | stop | kept (resume instant) | -| `marker resume` (project) | resume | resume | kept | - -The project-wide pause state is **persisted** in the `.gns3` file as -`markers_paused` and echoed on the project object (`GET /v3/projects/{pid}`, -the `asdict()` body), so the Web UI renders the mute button from server truth -rather than a local optimistic flag. Because `marker pause` is a uBridge -runtime flag that resets when a node restarts, `start_all` re-applies the mute -to freshly started uBridges after a project reopen — so a paused project stays -paused across close/reopen. +| per-marker `enabled: false` | stop | stop | n/a | +| per-def `pause` (all `global-{name}` copies) | stop | stop | n/a | +| per-def `resume` | resume | resume | n/a | ## API Endpoints @@ -202,14 +194,14 @@ All endpoints require a JWT bearer token (`POST /v3/access/users/authenticate`). | POST | `/v3/projects/{pid}/marker-definitions` | Create definition (fans out to every link) | Project.Modify | | PUT | `/v3/projects/{pid}/marker-definitions/{name}` | Update definition (syncs all copies) | Project.Modify | | DELETE | `/v3/projects/{pid}/marker-definitions/{name}` | Delete definition (clears all copies) | Project.Modify | +| POST | `/v3/projects/{pid}/marker-definitions/{name}/pause` | Pause every inherited copy (instant, persisted) | Project.Modify | +| POST | `/v3/projects/{pid}/marker-definitions/{name}/resume` | Resume every inherited copy | Project.Modify | ### Aggregation | Method | Path | Description | Auth | |--------|------|-------------|------| | GET | `/v3/projects/{pid}/markers` | All markers across links, flat | Project.Audit | -| POST | `/v3/projects/{pid}/markers/pause` | Mute all markers project-wide (signal+pcap) | Project.Modify | -| POST | `/v3/projects/{pid}/markers/resume` | Resume all markers project-wide | Project.Modify | The link object returned by `GET /v3/projects/{pid}/links[/{lid}]` also carries a `markers` field (including inherited markers), so the Web UI can render a link's markers without an @@ -270,6 +262,8 @@ extra request. "tag": 5, "color": null, "highlight_duration": 1200, + "direction": null, + "paused": false, "link_ids": ["656ed826-...", "6bd9d156-..."] } } @@ -298,6 +292,8 @@ extra request. | `tag` | int \| null | Correlation id | | `color` | string \| null | Hex color render hint | | `highlight_duration` | int \| null | UI highlight duration in ms; `null` = UI default | +| `direction` | string \| null | `tx` / `rx` filter relative to the capture node; `null` = both | +| `paused` | bool | Per-definition mute flag — `true` mutes every inherited copy (persisted) | | `link_ids` | string[] | Links currently carrying an inherited copy (GET only) | ### Notifications diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index 107392bfa..c9c1c0555 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -219,37 +219,6 @@ def get_project_markers(project: Project = Depends(dep_project)) -> dict: return project.markers -@router.post( - "/{project_id}/markers/pause", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Project.Modify"))] -) -async def pause_project_markers(project: Project = Depends(dep_project)) -> None: - """ - Pause marker signal+pcap emission project-wide (``marker pause`` on every - marker-hosting node's uBridge; resume is instant, sink retained). - - Required privilege: Project.Modify - """ - - await project.pause_all_markers() - - -@router.post( - "/{project_id}/markers/resume", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Project.Modify"))] -) -async def resume_project_markers(project: Project = Depends(dep_project)) -> None: - """ - Resume marker signal+pcap emission project-wide. - - Required privilege: Project.Modify - """ - - await project.resume_all_markers() - - # --------------------------------------------------------------------------- # Project-level marker definitions (global rules inherited by every link) # --------------------------------------------------------------------------- @@ -332,6 +301,44 @@ async def update_marker_definition( return project.marker_definitions.get(def_name, {}) +@router.post( + "/{project_id}/marker-definitions/{def_name}/pause", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(has_privilege("Project.Modify"))] +) +async def pause_marker_definition( + def_name: str, + project: Project = Depends(dep_project) +) -> None: + """ + Pause a definition: toggle off every inherited ``global-{def_name}`` copy + on every link (uBridge ``enable_packet_filter off``, instant — no NIO + rebuild). The definition's ``paused`` flag is persisted, so links created + later inherit it already paused. + + Required privilege: Project.Modify + """ + + await project.pause_marker_definition(def_name) + + +@router.post( + "/{project_id}/marker-definitions/{def_name}/resume", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(has_privilege("Project.Modify"))] +) +async def resume_marker_definition( + def_name: str, + project: Project = Depends(dep_project) +) -> None: + """Resume a paused definition (toggle on every inherited copy). + + Required privilege: Project.Modify + """ + + await project.resume_marker_definition(def_name) + + @router.delete( "/{project_id}/marker-definitions/{def_name}", status_code=status.HTTP_204_NO_CONTENT, diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 743abc676..607810d23 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -131,6 +131,7 @@ class Link: direction=marker_def.get("direction"), color=marker_def.get("color"), highlight_duration=marker_def.get("highlight_duration"), + enabled=not marker_def.get("paused", False), inherited_from=def_name, ) @@ -341,7 +342,7 @@ class Link: raise NotImplementedError - async def start_marker(self, name, bpf, tag=None, direction=None, capture_node_id=None): + async def start_marker(self, name, bpf, tag=None, direction=None, capture_node_id=None, enabled=True): """ Attach a traffic-insight marker to this link (base — UDPLink overrides). """ diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 371167883..72b0bf9f6 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -214,7 +214,6 @@ class Project: self._nodes = {} self._links = {} self._marker_definitions = {} # name → {bpf, tag, color, highlight_duration} - self._markers_paused = False # project-wide marker mute (persisted, see asdict) self._drawings = {} self._snapshots = {} self._computes = [] @@ -925,44 +924,37 @@ class Project: } return result - async def pause_all_markers(self): + async def pause_marker_definition(self, name): """ - Pause marker signal+pcap emission on every node hosting a marker - (``marker pause`` per capture node's uBridge). Node-deduplicated and - best-effort: a node hosting markers on several links is paused once, - and a node that is down or running an old compute is skipped. + Pause every inherited copy of a definition (``global-{name}``) on every + link: toggle each filter off in place via ``update_marker(enabled=False)`` + — uBridge ``enable_packet_filter off``, no NIO rebuild, pcap/emitted + preserved. The definition's ``paused`` flag is persisted, so links + created later inherit the marker already paused. """ - self._markers_paused = True - seen = set() + if name not in self._marker_definitions: + raise ControllerError(f"Marker definition '{name}' not found") + self._marker_definitions[name]["paused"] = True + marker_name = f"global-{name}" for link in list(self._links.values()): - for info in link.markers.values(): - node_id = info.get("capture_node_id") - if not node_id or node_id in seen: - continue - seen.add(node_id) - try: - await self.get_node(node_id).post("/markers/pause") - except Exception: - pass + if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name: + await link.update_marker(marker_name, enabled=False, inherited=True) self.dump() + self.emit_notification("project.updated", self.asdict()) - async def resume_all_markers(self): - """Resume marker signal+pcap emission on every marker-hosting node.""" + async def resume_marker_definition(self, name): + """Resume every inherited copy of a definition (toggle on).""" - self._markers_paused = False - seen = set() + if name not in self._marker_definitions: + raise ControllerError(f"Marker definition '{name}' not found") + self._marker_definitions[name]["paused"] = False + marker_name = f"global-{name}" for link in list(self._links.values()): - for info in link.markers.values(): - node_id = info.get("capture_node_id") - if not node_id or node_id in seen: - continue - seen.add(node_id) - try: - await self.get_node(node_id).post("/markers/resume") - except Exception: - pass + if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name: + await link.update_marker(marker_name, enabled=True, inherited=True) self.dump() + self.emit_notification("project.updated", self.asdict()) @property def marker_definitions(self): @@ -983,7 +975,7 @@ class Project: f"Marker definition '{name}' already exists in this project" ) - self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "direction": direction, "color": color, "highlight_duration": highlight_duration} + self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "direction": direction, "color": color, "highlight_duration": highlight_duration, "paused": False} await self._apply_def_to_all_links(name) self.dump() self.emit_notification("project.updated", self.asdict()) @@ -1477,7 +1469,6 @@ class Project: defs = project_data.get("marker_definitions") if isinstance(defs, dict): self._marker_definitions = defs - self._markers_paused = bool(project_data.get("markers_paused", False)) topology = project_data["topology"] for compute in topology.get("computes", []): @@ -1816,11 +1807,6 @@ class Project: if not node.is_always_running(): pool.append(node.start) await pool.join() - # marker pause is a uBridge runtime flag that resets when a node - # restarts, so re-apply the project-wide mute to the freshly started - # uBridges (markers are installed during node start). - if self._markers_paused: - await self.pause_all_markers() @open_required async def stop_all(self): @@ -1936,7 +1922,6 @@ class Project: "variables": self._variables, "created_by": self._created_by, "marker_definitions": self._marker_definitions, - "markers_paused": self._markers_paused, } def __repr__(self): diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index b71bdf291..67a9dfe6b 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -350,7 +350,7 @@ class UDPLink(Link): # explicitly deletes a marker via the REST API, and a marker is torn # down automatically only when its link is deleted. - async def start_marker(self, name, bpf, tag=None, direction=None, capture_node_id=None, color=None, highlight_duration=None, inherited_from=None): + async def start_marker(self, name, bpf, tag=None, direction=None, capture_node_id=None, color=None, highlight_duration=None, enabled=True, inherited_from=None): """ Attach a traffic-insight marker to this link. @@ -390,7 +390,7 @@ class UDPLink(Link): marker_entry = { "bpf": bpf, "tag": tag, - "enabled": True, + "enabled": enabled, "color": color, "highlight_duration": highlight_duration, "capture_node_id": marker_side["node"].id, @@ -472,7 +472,7 @@ class UDPLink(Link): and color is None and highlight_duration is None ) - if only_enabled and self._created and not marker_info.get("inherited_from"): + if only_enabled and self._created: capture_node_id = marker_info.get("capture_node_id") side = next((s for s in self._nodes if str(s["node"].id) == str(capture_node_id)), None) if side is not None: diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py index e78b3b60d..73dab47e8 100644 --- a/tests/controller/test_marker.py +++ b/tests/controller/test_marker.py @@ -512,29 +512,40 @@ async def test_update_marker_with_bpf_still_rebuilds_nio(project): # --------------------------------------------------------------------------- -# Part C: project-level pause/resume fan-out +# Per-definition pause/resume (toggle every inherited global-{name} copy) # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_pause_all_markers_fans_out_to_capture_nodes(project): - # Part C: project-level pause hits each marker-hosting node once. +async def test_pause_marker_definition_toggles_copies_off(project): with _valid_bpf(): - link = await _make_link(project) - await link.start_marker("m", "icmp") - node = link._nodes[0]["node"] - node.post = AsyncioMagicMock() - project.get_node = MagicMock(return_value=node) - await project.pause_all_markers() - node.post.assert_any_call("/markers/pause") + link1 = await _make_link(project) + link2 = await _make_link(project) + await project.create_marker_definition("arp", "arp") + assert link1.markers["global-arp"]["enabled"] is True + assert link2.markers["global-arp"]["enabled"] is True + await project.pause_marker_definition("arp") + assert project.marker_definitions["arp"]["paused"] is True + assert link1.markers["global-arp"]["enabled"] is False + assert link2.markers["global-arp"]["enabled"] is False @pytest.mark.asyncio -async def test_resume_all_markers_fans_out(project): +async def test_resume_marker_definition_toggles_copies_on(project): with _valid_bpf(): link = await _make_link(project) - await link.start_marker("m", "icmp") - node = link._nodes[0]["node"] - node.post = AsyncioMagicMock() - project.get_node = MagicMock(return_value=node) - await project.resume_all_markers() - node.post.assert_any_call("/markers/resume") + await project.create_marker_definition("arp", "arp") + await project.pause_marker_definition("arp") + assert link.markers["global-arp"]["enabled"] is False + await project.resume_marker_definition("arp") + assert project.marker_definitions["arp"]["paused"] is False + assert link.markers["global-arp"]["enabled"] is True + + +@pytest.mark.asyncio +async def test_paused_definition_inherited_as_disabled(project): + # A link created after the definition was paused inherits it already off. + with _valid_bpf(): + await project.create_marker_definition("arp", "arp") + await project.pause_marker_definition("arp") + new_link = await _make_link(project) + assert new_link.markers["global-arp"]["enabled"] is False diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py index 570fa7296..b0202c01f 100644 --- a/tests/controller/test_project.py +++ b/tests/controller/test_project.py @@ -83,7 +83,6 @@ async def test_json(): "supplier": None, "variables": None, "marker_definitions": {}, - "markers_paused": False, "created_by": None } From 0afbb897a5213a4f6ba9d19d5f053afe3c687a9a Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 2 Aug 2026 23:23:53 +0800 Subject: [PATCH 14/36] marker: make per-filter toggle a no-op when the marker isn't installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _ubridge_set_marker_filter_state raised "Marker X is not installed on this node" when the capture node wasn't running — the name->bridge map is only populated during _ubridge_apply_markers, which runs when the node is up. The error was noise: the controller's enabled-only short-circuit catches it and falls back, and the controller-layer enabled is authoritative (honoured when the node starts and applies the marker). Treat a missing entry as a no-op instead of raising. --- gns3server/compute/base_node.py | 6 +++++- gns3server/compute/iou/iou_vm.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index e1f9fa591..54092bcda 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1197,7 +1197,11 @@ class BaseNode: bridge_name = self._marker_filter_bridges.get(name) if not bridge_name: - raise UbridgeError(f"Marker '{name}' is not installed on this node") + # Marker not installed on this uBridge (node not started, or not yet + # applied). The controller-layer `enabled` is still authoritative and + # is honoured when the node starts and applies the marker, so a + # toggle here is a no-op rather than an error. + return state = "on" if enabled else "off" await self._ubridge_send(f"bridge enable_packet_filter {bridge_name} {name} {state}") diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index 33a4e6c4c..ef9cd28d8 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -1337,7 +1337,9 @@ class IOUVM(BaseNode): location = self._marker_filter_bridges.get(name) if not location: - raise UbridgeError(f"Marker '{name}' is not installed on this node") + # Marker not installed on this uBridge (node not started, or not yet + # applied); controller-layer `enabled` is authoritative. No-op. + return state = "on" if enabled else "off" await self._ubridge_send(f"iol_bridge enable_packet_filter {location} {name} {state}") From 34b644a5489e2bdf7059adc35c8cd97da599d26c Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 2 Aug 2026 23:41:43 +0800 Subject: [PATCH 15/36] marker: make per-filter toggle fall back to NIO rebuild when the marker isn't installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggle routes silently no-opped when _marker_filter_bridges lacked the filter name, so update_marker's enabled-only short-circuit succeeded without toggling uBridge — the controller-layer enabled was set but the uBridge filter stayed on and kept emitting signals. Now the toggle routes raise HTTPException 404 (FastAPI handles it directly, no ERROR log); the controller's except catches it and falls back to self.update() (NIO rebuild, which applies the marker + off). --- gns3server/api/routes/compute/cloud_nodes.py | 5 +++++ gns3server/api/routes/compute/docker_nodes.py | 5 +++++ gns3server/api/routes/compute/dynamips_nodes.py | 5 +++++ gns3server/api/routes/compute/iou_nodes.py | 5 +++++ gns3server/api/routes/compute/qemu_nodes.py | 5 +++++ gns3server/api/routes/compute/vpcs_nodes.py | 5 +++++ 6 files changed, 30 insertions(+) diff --git a/gns3server/api/routes/compute/cloud_nodes.py b/gns3server/api/routes/compute/cloud_nodes.py index 539c73a44..b6efc06ca 100644 --- a/gns3server/api/routes/compute/cloud_nodes.py +++ b/gns3server/api/routes/compute/cloud_nodes.py @@ -269,6 +269,11 @@ async def toggle_cloud_marker( Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). """ + if marker_name not 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} diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index a322bd56e..e40daeb72 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -423,6 +423,11 @@ async def toggle_docker_marker( Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). """ + if marker_name not 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} diff --git a/gns3server/api/routes/compute/dynamips_nodes.py b/gns3server/api/routes/compute/dynamips_nodes.py index 05c32e7a6..38c7a5624 100644 --- a/gns3server/api/routes/compute/dynamips_nodes.py +++ b/gns3server/api/routes/compute/dynamips_nodes.py @@ -382,6 +382,11 @@ async def toggle_dynamips_marker( Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). """ + if marker_name not 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} diff --git a/gns3server/api/routes/compute/iou_nodes.py b/gns3server/api/routes/compute/iou_nodes.py index e1c97819d..289993633 100644 --- a/gns3server/api/routes/compute/iou_nodes.py +++ b/gns3server/api/routes/compute/iou_nodes.py @@ -361,6 +361,11 @@ async def toggle_iou_marker( Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). """ + if marker_name not 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} diff --git a/gns3server/api/routes/compute/qemu_nodes.py b/gns3server/api/routes/compute/qemu_nodes.py index 7dc93869d..6ce9252d4 100644 --- a/gns3server/api/routes/compute/qemu_nodes.py +++ b/gns3server/api/routes/compute/qemu_nodes.py @@ -453,6 +453,11 @@ async def toggle_qemu_marker( Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). """ + if marker_name not 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} diff --git a/gns3server/api/routes/compute/vpcs_nodes.py b/gns3server/api/routes/compute/vpcs_nodes.py index fd478833a..2b796f979 100644 --- a/gns3server/api/routes/compute/vpcs_nodes.py +++ b/gns3server/api/routes/compute/vpcs_nodes.py @@ -360,6 +360,11 @@ async def toggle_vpcs_marker( Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). """ + if marker_name not 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} From ff907da5f66353792d46bb7c9c85d0cc4e9001ff Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 2 Aug 2026 23:48:24 +0800 Subject: [PATCH 16/36] marker: key _marker_filter_bridges by (name, link_id) so multi-link nodes toggle every copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _marker_filter_bridges dict was keyed by marker name alone, so when one node hosted the same filter name on several links (IUOL-BRIDGE per node with many bays/units, or a multi-interface router), successive apply calls overwrote earlier entries. pause_marker_definition then toggled only the last recorded bridge/location — other copies stayed active and kept emitting. Key by (name, link_id) so each copy is independent, and iterate all matching entries in _ubridge_set_marker_filter_state (both generic bridge and IOU iol_bridge override). Toggle route existence checks also iterate matching names. Tests updated. --- gns3server/api/routes/compute/cloud_nodes.py | 2 +- gns3server/api/routes/compute/docker_nodes.py | 2 +- .../api/routes/compute/dynamips_nodes.py | 2 +- gns3server/api/routes/compute/iou_nodes.py | 2 +- gns3server/api/routes/compute/qemu_nodes.py | 2 +- gns3server/api/routes/compute/vpcs_nodes.py | 2 +- gns3server/compute/base_node.py | 22 +++++++++---------- gns3server/compute/iou/iou_vm.py | 13 +++++------ tests/compute/test_base_node.py | 6 ++--- 9 files changed, 25 insertions(+), 28 deletions(-) diff --git a/gns3server/api/routes/compute/cloud_nodes.py b/gns3server/api/routes/compute/cloud_nodes.py index b6efc06ca..96fa825fd 100644 --- a/gns3server/api/routes/compute/cloud_nodes.py +++ b/gns3server/api/routes/compute/cloud_nodes.py @@ -269,7 +269,7 @@ async def toggle_cloud_marker( Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). """ - if marker_name not in node._marker_filter_bridges: + 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", diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index e40daeb72..96bda4b68 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -423,7 +423,7 @@ async def toggle_docker_marker( Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). """ - if marker_name not in node._marker_filter_bridges: + 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", diff --git a/gns3server/api/routes/compute/dynamips_nodes.py b/gns3server/api/routes/compute/dynamips_nodes.py index 38c7a5624..8ffdf4756 100644 --- a/gns3server/api/routes/compute/dynamips_nodes.py +++ b/gns3server/api/routes/compute/dynamips_nodes.py @@ -382,7 +382,7 @@ async def toggle_dynamips_marker( Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). """ - if marker_name not in node._marker_filter_bridges: + 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", diff --git a/gns3server/api/routes/compute/iou_nodes.py b/gns3server/api/routes/compute/iou_nodes.py index 289993633..728ef3ad3 100644 --- a/gns3server/api/routes/compute/iou_nodes.py +++ b/gns3server/api/routes/compute/iou_nodes.py @@ -361,7 +361,7 @@ async def toggle_iou_marker( Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). """ - if marker_name not in node._marker_filter_bridges: + 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", diff --git a/gns3server/api/routes/compute/qemu_nodes.py b/gns3server/api/routes/compute/qemu_nodes.py index 6ce9252d4..217531ff4 100644 --- a/gns3server/api/routes/compute/qemu_nodes.py +++ b/gns3server/api/routes/compute/qemu_nodes.py @@ -453,7 +453,7 @@ async def toggle_qemu_marker( Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). """ - if marker_name not in node._marker_filter_bridges: + 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", diff --git a/gns3server/api/routes/compute/vpcs_nodes.py b/gns3server/api/routes/compute/vpcs_nodes.py index 2b796f979..ca2c2175b 100644 --- a/gns3server/api/routes/compute/vpcs_nodes.py +++ b/gns3server/api/routes/compute/vpcs_nodes.py @@ -360,7 +360,7 @@ async def toggle_vpcs_marker( Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). """ - if marker_name not in node._marker_filter_bridges: + 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", diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 54092bcda..7362aa68e 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1181,29 +1181,29 @@ class BaseNode: ) # Remember which bridge hosts this filter so an instant on/off toggle # (no NIO rebuild) can resolve it by name alone. - self._marker_filter_bridges[name] = bridge_name + # keyed (name, link_id) so a node that hosts markers for several links + # (e.g. IOU with one IOL-BRIDGE and many bays/units) records each + # copy independently — toggle below iterates all matching entries. + self._marker_filter_bridges[name, link_id] = bridge_name async def _ubridge_set_marker_filter_state(self, name, enabled): """ Toggle an installed marker filter on/off with a single uBridge command (``bridge enable_packet_filter … on|off``) — no NIO reset/reapply, so the pcap identity and emitted counter are preserved (ubridge contract §3.2). - The bridge is resolved from the name→bridge map populated at apply time; - IOU overrides this for its ``iol_bridge`` command shape. + The bridge is resolved from the (name, link_id)→bridge map populated at + apply time; entries are iterated so a node that hosts the same marker name + on several links (e.g. IOU with one IOL-BRIDGE per node) toggles every + copy. IOU overrides this for its ``iol_bridge`` command shape. :param name: marker filter name :param enabled: True = on (signal+pcap), False = off (paused tap) """ - bridge_name = self._marker_filter_bridges.get(name) - if not bridge_name: - # Marker not installed on this uBridge (node not started, or not yet - # applied). The controller-layer `enabled` is still authoritative and - # is honoured when the node starts and applies the marker, so a - # toggle here is a no-op rather than an error. - return state = "on" if enabled else "off" - await self._ubridge_send(f"bridge enable_packet_filter {bridge_name} {name} {state}") + for (n, lid), bridge_name in list(self._marker_filter_bridges.items()): + if n == name: + await self._ubridge_send(f"bridge enable_packet_filter {bridge_name} {name} {state}") async def _ubridge_marker_pause(self): """ diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index ef9cd28d8..c24340171 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -1330,18 +1330,15 @@ class IOUVM(BaseNode): str(self.project.id), self._id, name, link_id, tag ) # Record name -> location (bridge bay unit) for instant toggle. - self._marker_filter_bridges[name] = location + self._marker_filter_bridges[name, link_id] = location async def _ubridge_set_marker_filter_state(self, name, enabled): - """IOU override: toggle via ``iol_bridge enable_packet_filter on|off``.""" + """IOU override: toggle every (name, link_id) entry via ``iol_bridge``.""" - location = self._marker_filter_bridges.get(name) - if not location: - # Marker not installed on this uBridge (node not started, or not yet - # applied); controller-layer `enabled` is authoritative. No-op. - return state = "on" if enabled else "off" - await self._ubridge_send(f"iol_bridge enable_packet_filter {location} {name} {state}") + for (n, lid), location in list(self._marker_filter_bridges.items()): + if n == name: + await self._ubridge_send(f"iol_bridge enable_packet_filter {location} {name} {state}") async def adapter_remove_nio_binding(self, adapter_number, port_number): """ diff --git a/tests/compute/test_base_node.py b/tests/compute/test_base_node.py index cee8644f3..764af1e6e 100644 --- a/tests/compute/test_base_node.py +++ b/tests/compute/test_base_node.py @@ -180,7 +180,7 @@ async def test_set_marker_filter_state_off(compute_project, manager): node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) node._ubridge_send = AsyncioMagicMock() - node._marker_filter_bridges["m"] = "VPCS-10" + node._marker_filter_bridges["m", "L"] = "VPCS-10" await node._ubridge_set_marker_filter_state("m", False) node._ubridge_send.assert_called_with("bridge enable_packet_filter VPCS-10 m off") @@ -190,7 +190,7 @@ async def test_set_marker_filter_state_on(compute_project, manager): node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) node._ubridge_send = AsyncioMagicMock() - node._marker_filter_bridges["m"] = "VPCS-10" + node._marker_filter_bridges["m", "L"] = "VPCS-10" await node._ubridge_set_marker_filter_state("m", True) node._ubridge_send.assert_called_with("bridge enable_packet_filter VPCS-10 m on") @@ -226,4 +226,4 @@ async def test_apply_markers_turns_disabled_filter_off(compute_project, manager) mm.instance.return_value.register = MagicMock() await node._ubridge_apply_markers("VPCS-10", nio) node._ubridge_send.assert_any_call("bridge enable_packet_filter VPCS-10 m off") - assert node._marker_filter_bridges["m"] == "VPCS-10" + assert node._marker_filter_bridges["m", "L1"] == "VPCS-10" From e3ce234a09b92ea43bfbf055f98594a95d9d0b26 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 3 Aug 2026 00:05:08 +0800 Subject: [PATCH 17/36] docs: add log-interpretation note across node types for marker operations --- docs/features/marker-traffic-insight.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/features/marker-traffic-insight.md b/docs/features/marker-traffic-insight.md index 3ca90e891..1995b414d 100644 --- a/docs/features/marker-traffic-insight.md +++ b/docs/features/marker-traffic-insight.md @@ -341,3 +341,12 @@ direction relative to the capture node; see [Direction](#direction). - **Persistence.** Definitions and private markers persist in the topology; inherited markers are re-created from definitions on project load, so reopening a project restores the same configuration and stale inherited copies cannot survive on disk. +- **Log interpretation across node types.** Each node type logs its startup and link + operations differently — do not mistake sparse logs from one type for inactivity. + QEMU prints `set_link gns3- on` via its QEMU monitor, which is the most visible + startup log among all types. VPCS, Docker, IOU, Dynamips, and Cloud each have their own + startup paths (fork + ubridge, container veth, iouyap, Dynamips hypervisor, and TAP + device respectively) and none of them emit QEMU-monitor-style logs. To verify marker + operations (toggle, pause, resume) on non-QEMU types, either inspect uBridge's + own log for `enable_packet_filter` / `marker pause` / `marker resume` commands, or + watch the gns3server log for the corresponding compute-route calls at INFO level. From b6959f82140bdb77b22a28cf532409c1cb98e49e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 3 Aug 2026 00:17:34 +0800 Subject: [PATCH 18/36] qemu: demote QEMU monitor connect and set_link logs to DEBUG --- gns3server/compute/qemu/qemu_vm.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py index 92884e0c7..c4866fbb0 100644 --- a/gns3server/compute/qemu/qemu_vm.py +++ b/gns3server/compute/qemu/qemu_vm.py @@ -1338,7 +1338,7 @@ class QemuVM(BaseNode): ) ) else: - log.info( + log.debug( f"Connected to QEMU monitor on {self._monitor_host}:{self._monitor} after {time.time() - begin:.4f} seconds" ) return reader, writer @@ -1355,7 +1355,7 @@ class QemuVM(BaseNode): result = None if self.is_running() and self._monitor: - log.info(f"Execute QEMU monitor command: {command}") + log.debug(f"Execute QEMU monitor command: {command}") reader, writer = await self._open_qemu_monitor_connection_vm() if reader is None and writer is None: return result @@ -1405,7 +1405,7 @@ class QemuVM(BaseNode): return for command in commands: - log.info(f"Execute QEMU monitor command: {command}") + log.debug(f"Execute QEMU monitor command: {command}") try: cmd_byte = command.encode("ascii") writer.write(cmd_byte + b"\n") From 9323ea4cec15fc5433af39aaa5ddf8952e5457ef Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 3 Aug 2026 00:31:45 +0800 Subject: [PATCH 19/36] schema: accept direction='both' in marker schemas, normalize to None --- gns3server/schemas/controller/links.py | 29 +++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 31bdc607a..dd4b4ce5c 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from typing import List, Optional, Tuple from enum import Enum from uuid import UUID, uuid4 @@ -177,8 +177,8 @@ class MarkerCreate(BaseModel): ) direction: Optional[str] = Field( None, - pattern=r"^(tx|rx)$", - description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only. Omitted or null = both directions.", + pattern=r"^(tx|rx|both)$", + description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only, 'both' or null = both directions.", ) capture_node_id: Optional[UUID] = Field( None, @@ -190,6 +190,11 @@ class MarkerCreate(BaseModel): ), ) + @field_validator("direction", mode="before") + @classmethod + def _both_to_none(cls, v): + return None if v == "both" else v + class MarkerUpdate(BaseModel): """ @@ -204,8 +209,8 @@ class MarkerUpdate(BaseModel): tag: Optional[int] = None direction: Optional[str] = Field( None, - pattern=r"^(tx|rx)$", - description="Direction filter; an explicit null clears it to both. Omit to keep.", + pattern=r"^(tx|rx|both)$", + description="Direction filter; 'both' or an explicit null clears it to both. Omit to keep.", ) color: Optional[str] = Field(None, description="Hex color render hint, e.g. '#ff5722'") highlight_duration: Optional[int] = Field( @@ -213,6 +218,11 @@ class MarkerUpdate(BaseModel): ) enabled: Optional[bool] = Field(None, description="Toggle the marker on/off (instant).") + @field_validator("direction", mode="before") + @classmethod + def _both_to_none(cls, v): + return None if v == "both" else v + class MarkerDefinitionCreate(BaseModel): """ @@ -246,8 +256,13 @@ class MarkerDefinitionCreate(BaseModel): ) direction: Optional[str] = Field( None, - pattern=r"^(tx|rx)$", - description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only. Omitted or null = both directions.", + pattern=r"^(tx|rx|both)$", + description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only, 'both' or null = both directions.", ) + @field_validator("direction", mode="before") + @classmethod + def _both_to_none(cls, v): + return None if v == "both" else v + From 19815f7a372ef6d210431704be2652d794f43f57 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 4 Aug 2026 10:14:02 +0800 Subject: [PATCH 20/36] marker: restore direction on project load, reject tx/rx on definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore a private marker's direction (and highlight_duration) when a project is reopened — _create_link_from_topology_data previously dropped them, silently reverting rx/tx markers to "both". Reject tx/rx direction on marker definitions with HTTP 409: a definition auto-selects its capture node per link and direction is relative to that node, so a fixed tx/rx has no stable project-wide meaning. Per-link markers still support tx/rx; only the project-wide definition is restricted to "both" (the default). --- gns3server/controller/project.py | 19 +++++ tests/controller/test_marker.py | 116 ++++++++++++++++++++++++++++++- 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 72b0bf9f6..bb3f5f103 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -791,7 +791,9 @@ class Project: "tag": marker.get("tag"), "enabled": marker.get("enabled", True), "color": marker.get("color"), + "highlight_duration": marker.get("highlight_duration"), "capture_node_id": marker.get("capture_node_id"), + "direction": marker.get("direction"), } if "link_style" in link_data: await link.update_link_style(link_data["link_style"]) @@ -963,6 +965,21 @@ class Project: """ return self._marker_definitions + def _validate_marker_definition_direction(self, name, direction): + """ + Reject tx/rx on a marker definition: a definition auto-selects its + capture node per link (``_choose_marker_side``), while tx/rx is + interpreted from that node's perspective, so a fixed direction has no + stable meaning project-wide. Only 'both' (the default, = ``None``) is + allowed — use a per-link marker if a directional filter is needed. + """ + if direction in ("tx", "rx"): + raise ControllerError( + f"Marker definition '{name}': direction '{direction}' is not allowed. " + "A definition auto-selects its capture node per link and tx/rx is " + "relative to that node — use 'both' (the default), or a per-link marker." + ) + async def create_marker_definition(self, name, bpf, tag=None, direction=None, color=None, highlight_duration=None): """ Create a project-level marker definition and fan out to every existing @@ -975,6 +992,7 @@ class Project: f"Marker definition '{name}' already exists in this project" ) + self._validate_marker_definition_direction(name, direction) self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "direction": direction, "color": color, "highlight_duration": highlight_duration, "paused": False} await self._apply_def_to_all_links(name) self.dump() @@ -1000,6 +1018,7 @@ class Project: if highlight_duration is not None: d["highlight_duration"] = highlight_duration if direction is not _UNSET: + self._validate_marker_definition_direction(name, direction) d["direction"] = direction # None = clear back to both directions # Sync: update every inherited copy across all links. diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py index 73dab47e8..991fef5fa 100644 --- a/tests/controller/test_marker.py +++ b/tests/controller/test_marker.py @@ -24,6 +24,8 @@ Controller-layer tests for the traffic-insight marker feature: * Project.apply_defs_to_new_link and the markers aggregation property. """ +import uuid + import pytest from unittest.mock import MagicMock, patch @@ -286,6 +288,78 @@ async def test_persist_markers_excludes_inherited(project): assert "global-arp" not in persisted +@pytest.mark.asyncio +async def test_load_marker_preserves_direction_and_highlight_duration(project): + """Regression: a private marker's direction + highlight_duration must survive + a close/reopen round-trip through the topology file. + + _create_link_from_topology_data previously restored only bpf/tag/enabled/ + color/capture_node_id, silently dropping direction (→ reverted to "both") + and highlight_duration. + """ + compute = MagicMock() + compute.id = "local" + compute.host = "example.com" + + async def subnet(_other): + return ("192.168.1.1", "192.168.1.2") + + async def udp_cb(path, data={}, **kwargs): + response = MagicMock() + response.json = {"udp_port": 1234} + return response + + compute.get_ip_on_same_subnet.side_effect = subnet + compute.post.side_effect = udp_cb + # Attaching the 2nd node auto-creates the link (NIO round-trips). + compute.put = AsyncioMagicMock() + compute.delete = AsyncioMagicMock() + + node1 = Node(project, compute, "n1", node_type="vpcs") + node1._ports = [EthernetPort("E0", 0, 0, 0)] + node2 = Node(project, compute, "n2", node_type="vpcs") + node2._ports = [EthernetPort("E0", 0, 0, 1)] + # _create_link_from_topology_data resolves nodes via project.get_node(). + project._nodes[node1.id] = node1 + project._nodes[node2.id] = node2 + + capture_node_id = str(uuid.uuid4()) + link_id = str(uuid.uuid4()) + link_data = { + "link_id": link_id, + "nodes": [ + {"node_id": node1.id, "adapter_number": 0, "port_number": 0, "label": "a"}, + {"node_id": node2.id, "adapter_number": 0, "port_number": 1, "label": "b"}, + ], + "markers": { + "icmp": { + "bpf": "icmp", + "direction": "rx", + "highlight_duration": 800, + "tag": 7, + "color": "#ff5722", + "enabled": True, + "capture_node_id": capture_node_id, + } + }, + } + with patch( + "gns3server.controller.project.validate_bpf_syntax", + return_value={"valid": True, "error": None}, + ): + await project._create_link_from_topology_data(link_data) + + # The link survives (2 attached nodes); pull it back from the project. + link = project._links[link_id] + entry = link._markers["icmp"] + assert entry["direction"] == "rx" # dropped before the fix + assert entry["highlight_duration"] == 800 # dropped before the fix + assert entry["tag"] == 7 + assert entry["color"] == "#ff5722" + assert entry["capture_node_id"] == capture_node_id + assert entry["enabled"] is True + + @pytest.mark.asyncio async def test_asdict_markers_runtime_vs_dump(project): """Runtime asdict exposes all markers; topology dump drops inherited ones.""" @@ -410,10 +484,16 @@ async def test_update_marker_preserves_direction_when_omitted(project): @pytest.mark.asyncio async def test_update_marker_definition_clears_direction(project): # Clearing a definition's direction must propagate to every inherited copy. + # New defs can't carry tx/rx, but a legacy def loaded from an old topology + # could — so inject one and confirm a clear syncs every copy. with _valid_bpf(): link1 = await _make_link(project) link2 = await _make_link(project) - await project.create_marker_definition("arp", "arp", direction="tx") + await project.create_marker_definition("arp", "arp") + # Simulate a legacy directional value persisted before the restriction. + project._marker_definitions["arp"]["direction"] = "tx" + await link1.update_marker("global-arp", direction="tx", inherited=True) + await link2.update_marker("global-arp", direction="tx", inherited=True) for link in (link1, link2): assert link.markers["global-arp"]["direction"] == "tx" await project.update_marker_definition("arp", direction=None) @@ -549,3 +629,37 @@ async def test_paused_definition_inherited_as_disabled(project): await project.pause_marker_definition("arp") new_link = await _make_link(project) assert new_link.markers["global-arp"]["enabled"] is False + + +# --------------------------------------------------------------------------- +# Marker definition direction (tx/rx rejected — it is capture-node-relative) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_marker_definition_rejects_directional(project): + # tx/rx is relative to the auto-selected capture node → rejected at the def level. + with pytest.raises(ControllerError): + await project.create_marker_definition("arp", "arp", direction="tx") + with pytest.raises(ControllerError): + await project.create_marker_definition("arp", "arp", direction="rx") + assert "arp" not in project.marker_definitions # nothing created + + +@pytest.mark.asyncio +async def test_create_marker_definition_allows_both(project): + await project.create_marker_definition("arp", "arp") # default both + await project.create_marker_definition("icmp", "icmp", direction=None) + assert project.marker_definitions["arp"]["direction"] is None + assert project.marker_definitions["icmp"]["direction"] is None + + +@pytest.mark.asyncio +async def test_update_marker_definition_rejects_directional(project): + await project.create_marker_definition("arp", "arp") # both + with pytest.raises(ControllerError): + await project.update_marker_definition("arp", direction="tx") + # omitted direction and explicit clear-to-both are both fine + await project.update_marker_definition("arp", color="#ffffff") + await project.update_marker_definition("arp", direction=None) + assert project.marker_definitions["arp"]["direction"] is None From caec71aa71918ffebc2f49ae8738655bd97a1ecc Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 4 Aug 2026 21:35:31 +0800 Subject: [PATCH 21/36] marker: fine-grained filter ops, clean pcap on remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting or updating a marker no longer triggers a full NIO reapply (reset_packet_filters + re-add), which closed/reopened every sibling marker's pcap via uBridge. Instead operate on single filters: - stop_marker: bridge delete_packet_filter + unlink the pcap (works with the node stopped; filter removal is skipped, the file is still deleted). - update_marker: bpf/tag/direction → rebuild just that filter (delete + add); enabled → instant toggle; color/highlight_duration → stored only. - compute delete_marker_capture / rebuild_marker_filter + per-node routes (DELETE /markers/{name}, PUT /markers/{name}/rebuild) + MarkerRebuild schema. IOU overrides _ubridge_delete_marker_filter for iol_bridge; rebuild reuses the already-overridden add/delete/set, so IOU needs no rebuild override. --- gns3server/api/routes/compute/cloud_nodes.py | 35 ++++++++ gns3server/api/routes/compute/docker_nodes.py | 39 +++++++++ .../api/routes/compute/dynamips_nodes.py | 39 +++++++++ gns3server/api/routes/compute/iou_nodes.py | 39 +++++++++ gns3server/api/routes/compute/qemu_nodes.py | 39 +++++++++ gns3server/api/routes/compute/vpcs_nodes.py | 39 +++++++++ gns3server/compute/base_node.py | 55 +++++++++++++ gns3server/compute/iou/iou_vm.py | 11 +++ gns3server/controller/udp_link.py | 79 +++++++++++-------- gns3server/schemas/__init__.py | 2 +- gns3server/schemas/compute/nios.py | 16 ++++ tests/compute/test_base_node.py | 62 +++++++++++++++ tests/controller/test_marker.py | 38 ++++++++- 13 files changed, 456 insertions(+), 37 deletions(-) diff --git a/gns3server/api/routes/compute/cloud_nodes.py b/gns3server/api/routes/compute/cloud_nodes.py index 96fa825fd..d33023053 100644 --- a/gns3server/api/routes/compute/cloud_nodes.py +++ b/gns3server/api/routes/compute/cloud_nodes.py @@ -294,3 +294,38 @@ async def pause_cloud_markers(node: Cloud = Depends(dep_node)) -> None: async def resume_cloud_markers(node: Cloud = Depends(dep_node)) -> None: await node._ubridge_marker_resume() + + +@router.delete( + "/{node_id}/markers/{marker_name}", + status_code=status.HTTP_204_NO_CONTENT +) +async def delete_cloud_marker_capture( + marker_name: str, + link_id: str = "", + node: Cloud = 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 node stopped. + """ + + await node.delete_marker_capture(marker_name, link_id) + + +@router.put("/{node_id}/markers/{marker_name}/rebuild") +async def rebuild_cloud_marker( + marker_name: str, + rebuild_data: schemas.MarkerRebuild, + node: Cloud = 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} diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index 96bda4b68..a5a797972 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -450,3 +450,42 @@ async def pause_docker_markers(node: DockerVM = Depends(dep_node)) -> None: async def resume_docker_markers(node: DockerVM = Depends(dep_node)) -> None: await node._ubridge_marker_resume() + + +@router.delete( + "/{node_id}/markers/{marker_name}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def delete_docker_marker_capture( + marker_name: str, + link_id: str = "", + node: DockerVM = 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 node stopped. + """ + + await node.delete_marker_capture(marker_name, link_id) + + +@router.put( + "/{node_id}/markers/{marker_name}/rebuild", + dependencies=[Depends(compute_authentication)] +) +async def rebuild_docker_marker( + marker_name: str, + rebuild_data: schemas.MarkerRebuild, + node: DockerVM = 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} diff --git a/gns3server/api/routes/compute/dynamips_nodes.py b/gns3server/api/routes/compute/dynamips_nodes.py index 8ffdf4756..863f192ab 100644 --- a/gns3server/api/routes/compute/dynamips_nodes.py +++ b/gns3server/api/routes/compute/dynamips_nodes.py @@ -409,3 +409,42 @@ async def pause_dynamips_markers(node: Router = Depends(dep_node)) -> None: async def resume_dynamips_markers(node: Router = Depends(dep_node)) -> None: await node._ubridge_marker_resume() + + +@router.delete( + "/{node_id}/markers/{marker_name}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def delete_dynamips_marker_capture( + marker_name: str, + link_id: str = "", + node: Router = 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 node stopped. + """ + + await node.delete_marker_capture(marker_name, link_id) + + +@router.put( + "/{node_id}/markers/{marker_name}/rebuild", + dependencies=[Depends(compute_authentication)] +) +async def rebuild_dynamips_marker( + marker_name: str, + rebuild_data: schemas.MarkerRebuild, + node: Router = 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} diff --git a/gns3server/api/routes/compute/iou_nodes.py b/gns3server/api/routes/compute/iou_nodes.py index 728ef3ad3..73d78a54e 100644 --- a/gns3server/api/routes/compute/iou_nodes.py +++ b/gns3server/api/routes/compute/iou_nodes.py @@ -388,3 +388,42 @@ async def pause_iou_markers(node: IOUVM = Depends(dep_node)) -> None: async def resume_iou_markers(node: IOUVM = Depends(dep_node)) -> None: await node._ubridge_marker_resume() + + +@router.delete( + "/{node_id}/markers/{marker_name}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def delete_iou_marker_capture( + marker_name: str, + link_id: str = "", + node: IOUVM = 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 node stopped. + """ + + await node.delete_marker_capture(marker_name, link_id) + + +@router.put( + "/{node_id}/markers/{marker_name}/rebuild", + dependencies=[Depends(compute_authentication)] +) +async def rebuild_iou_marker( + marker_name: str, + rebuild_data: schemas.MarkerRebuild, + node: IOUVM = 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} diff --git a/gns3server/api/routes/compute/qemu_nodes.py b/gns3server/api/routes/compute/qemu_nodes.py index 217531ff4..aa353bc5f 100644 --- a/gns3server/api/routes/compute/qemu_nodes.py +++ b/gns3server/api/routes/compute/qemu_nodes.py @@ -480,3 +480,42 @@ async def pause_qemu_markers(node: QemuVM = Depends(dep_node)) -> None: async def resume_qemu_markers(node: QemuVM = Depends(dep_node)) -> None: await node._ubridge_marker_resume() + + +@router.delete( + "/{node_id}/markers/{marker_name}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def delete_qemu_marker_capture( + marker_name: str, + link_id: str = "", + node: QemuVM = 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 node stopped. + """ + + await node.delete_marker_capture(marker_name, link_id) + + +@router.put( + "/{node_id}/markers/{marker_name}/rebuild", + dependencies=[Depends(compute_authentication)] +) +async def rebuild_qemu_marker( + marker_name: str, + rebuild_data: schemas.MarkerRebuild, + node: QemuVM = 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} diff --git a/gns3server/api/routes/compute/vpcs_nodes.py b/gns3server/api/routes/compute/vpcs_nodes.py index ca2c2175b..251e18e16 100644 --- a/gns3server/api/routes/compute/vpcs_nodes.py +++ b/gns3server/api/routes/compute/vpcs_nodes.py @@ -387,3 +387,42 @@ async def pause_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None: async def resume_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None: await node._ubridge_marker_resume() + + +@router.delete( + "/{node_id}/markers/{marker_name}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def delete_vpcs_marker_capture( + marker_name: str, + link_id: str = "", + node: VPCSVM = 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 node stopped. + """ + + await node.delete_marker_capture(marker_name, link_id) + + +@router.put( + "/{node_id}/markers/{marker_name}/rebuild", + dependencies=[Depends(compute_authentication)] +) +async def rebuild_vpcs_marker( + marker_name: str, + rebuild_data: schemas.MarkerRebuild, + node: VPCSVM = 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} diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 7362aa68e..7cdd15fce 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1130,6 +1130,61 @@ class BaseNode: # bad expression must surface instead of being silently dropped. await self._ubridge_send(cmd) + async def delete_marker_capture(self, name, link_id): + """ + Remove a marker from uBridge (fine-grained ``delete_packet_filter`` — NOT + reset_packet_filters, so sibling markers' pcaps aren't closed/reopened) + and delete its capture pcap. Called by the controller when a marker is + removed; safe with the node stopped (filter removal is skipped, the file + is still unlinked). IOU overrides ``_ubridge_delete_marker_filter`` for + its ``iol_bridge`` command shape. + """ + bridge_name = self._marker_filter_bridges.pop((name, link_id), None) + if bridge_name is not None: + await self._ubridge_delete_marker_filter(bridge_name, name) + try: + markers_dir = self.project.markers_working_directory() + pcap_path = os.path.join(markers_dir, f"{self._id}_{link_id}_{name}.pcap") + os.remove(pcap_path) + except FileNotFoundError: + pass + except OSError as e: + log.warning("Could not remove marker pcap for '%s' on link %s: %s", name, link_id, e) + + async def _ubridge_delete_marker_filter(self, bridge_name, name): + """ + Remove a single marker filter from uBridge with ``delete_packet_filter`` + (not a bridge-wide reset) so other markers keep their pcaps open. A no-op + when uBridge isn't running — the pcap cleanup in the caller still proceeds. + """ + if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running()): + return + try: + await self._ubridge_send(f"bridge delete_packet_filter {bridge_name} {name}") + except UbridgeError as e: + log.warning("Could not remove marker filter '%s' from %s: %s", name, bridge_name, e) + + async def rebuild_marker_filter(self, name, link_id, bpf, tag=None, direction=None, enabled=True): + """ + Re-install a single marker filter with new params (delete + add), without + a bridge-wide reset — so sibling markers keep their pcaps open. uBridge + reopens the marker's own pcap on re-add (a new capture session for the + new BPF), which is expected. No-op if the marker isn't installed (node + stopped) — the next NIO reapply picks up the updated ``_markers``. + + IOU needs no override: this calls ``_ubridge_delete_marker_filter`` / + ``_ubridge_add_marker_filter`` / ``_ubridge_set_marker_filter_state``, + all of which IOU already overrides for ``iol_bridge``. + """ + bridge_name = self._marker_filter_bridges.get((name, link_id)) + if bridge_name is None: + return + await self._ubridge_delete_marker_filter(bridge_name, name) + pcap_path = os.path.join(self.project.markers_working_directory(), f"{self._id}_{link_id}_{name}.pcap") + await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag, link_id, direction=direction) + if not enabled: + await self._ubridge_set_marker_filter_state(name, enabled=False) + async def _ubridge_apply_markers(self, bridge_name, nio): """ (Re-)apply every traffic-insight marker carried by *nio* to the uBridge diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index c24340171..52a8f63d9 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -1340,6 +1340,17 @@ class IOUVM(BaseNode): if n == name: await self._ubridge_send(f"iol_bridge enable_packet_filter {location} {name} {state}") + async def _ubridge_delete_marker_filter(self, location, name): + """IOU override: remove a single marker filter via ``iol_bridge`` + (location = ``{bridge} {bay} {unit}``), not a bridge-wide reset.""" + + if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running()): + return + try: + await self._ubridge_send(f"iol_bridge delete_packet_filter {location} {name}") + except UbridgeError as e: + log.warning("Could not remove marker filter '%s' from %s: %s", name, location, e) + async def adapter_remove_nio_binding(self, adapter_number, port_number): """ Removes an adapter NIO binding. diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 67a9dfe6b..13c202751 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -427,17 +427,29 @@ class UDPLink(Link): "Delete or update it via the marker-definitions API instead." ) + capture_node_id = self._markers[name].get("capture_node_id") del self._markers[name] - if self._created: - await self.update() + # Remove the marker filter + its pcap on the capture node directly — NOT a + # full NIO reapply (which would reset_packet_filters and close/reopen every + # sibling marker's pcap). delete_packet_filter removes just this filter; + # the marker is already gone from _markers, so any later reapply (filter + # change, node restart) won't re-add it either. + if capture_node_id is not None: + side = next((s for s in self._nodes if str(s["node"].id) == str(capture_node_id)), None) + if side is not None: + try: + await side["node"].delete(f"/markers/{name}", params={"link_id": self._id}) + except Exception: + pass # best-effort: old compute without the route leaves the file self._project.emit_notification("link.updated", self.asdict()) self._project.dump() async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=_UNSET, color=None, highlight_duration=None, inherited=False): """ - Update an existing marker's BPF/tag/enabled/color. Any change pushes via - ``self.update()``; uBridge picks up the new params on the next NIO - reset+reapply (same as packet filters). + Update an existing marker's fields and push to uBridge fine-grained — no + full NIO reapply, so sibling markers' pcaps stay open. bpf/tag/direction + rebuild just this filter (delete + add); enabled is an instant toggle; + color/highlight_duration are UI-only (stored, never pushed). :param name: filter name to update :param bpf: new BPF expression (None = keep existing) @@ -460,33 +472,7 @@ class UDPLink(Link): "Update it via the marker-definitions API instead." ) - # Instant toggle: when only `enabled` changes, send a single - # enable_packet_filter on|off to the capture node instead of rebuilding - # the whole NIO (no pcap flush, emitted counter preserved). Falls through - # to the full reset+reapply below if the compute route is unavailable. - only_enabled = ( - enabled is not None - and bpf is None - and tag is None - and direction is _UNSET - and color is None - and highlight_duration is None - ) - if only_enabled and self._created: - capture_node_id = marker_info.get("capture_node_id") - side = next((s for s in self._nodes if str(s["node"].id) == str(capture_node_id)), None) - if side is not None: - try: - await side["node"].put(f"/markers/{name}", data={"enabled": enabled}) - marker_info["enabled"] = enabled - self._project.emit_notification("link.updated", self.asdict()) - self._project.dump() - return - except Exception: - # Old compute without the toggle route / node down: fall - # through to the full NIO reset+reapply below. - pass - + # Merge every changed field into the marker state first. if bpf is not None and bpf != marker_info["bpf"]: result = validate_bpf_syntax(bpf) if not result.get("valid"): @@ -503,7 +489,34 @@ class UDPLink(Link): if direction is not _UNSET: marker_info["direction"] = direction # None = clear back to both directions + # Push to uBridge fine-grained — NO full NIO reapply (which would + # reset_packet_filters and close/reopen every sibling marker's pcap): + # * bpf/tag/direction changed → rebuild just this filter (delete + add), + # reopening only this marker's pcap (expected, new BPF) + # * only enabled changed → instant toggle (enable_packet_filter) + # * only UI fields changed → nothing to push to uBridge if self._created: - await self.update() + ubridge_rebuild = (bpf is not None) or (tag is not None) or (direction is not _UNSET) + capture_node_id = marker_info.get("capture_node_id") + side = next((s for s in self._nodes if str(s["node"].id) == str(capture_node_id)), None) + if side is not None: + try: + if ubridge_rebuild: + await side["node"].put( + f"/markers/{name}/rebuild", + data={ + "bpf": marker_info["bpf"], + "tag": marker_info.get("tag"), + "direction": marker_info.get("direction"), + "enabled": marker_info.get("enabled", True), + "link_id": self._id, + }, + ) + elif enabled is not None: + await side["node"].put(f"/markers/{name}", data={"enabled": enabled}) + except Exception: + # Old compute without the route / node down: state is already + # correct in _markers; the next NIO reapply converges uBridge. + pass self._project.emit_notification("link.updated", self.asdict()) self._project.dump() diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index fc18b73af..af026c241 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -91,7 +91,7 @@ from .controller.templates.dynamips_templates import ( ) # Compute schemas -from .compute.nios import UDPNIO, TAPNIO, EthernetNIO, MarkerToggle +from .compute.nios import UDPNIO, TAPNIO, EthernetNIO, MarkerToggle, MarkerRebuild from .compute.atm_switch_nodes import ATMSwitchCreate, ATMSwitchUpdate, ATMSwitch from .compute.cloud_nodes import CloudCreate, CloudUpdate, Cloud from .compute.docker_nodes import DockerCreate, DockerUpdate, Docker diff --git a/gns3server/schemas/compute/nios.py b/gns3server/schemas/compute/nios.py index f9a7ce7e6..5830847c5 100644 --- a/gns3server/schemas/compute/nios.py +++ b/gns3server/schemas/compute/nios.py @@ -75,3 +75,19 @@ class MarkerToggle(BaseModel): """ enabled: bool + + +class MarkerRebuild(BaseModel): + """ + Body for the per-marker rebuild endpoint: re-install a single uBridge marker + filter with new BPF/tag/direction via ``delete_packet_filter`` + add (NOT a + bridge-wide reset), so sibling markers keep their pcaps open. The marker's + own pcap is reopened by uBridge on re-add (new capture session for the new + BPF), which is expected. + """ + + bpf: str + tag: Optional[int] = None + direction: Optional[str] = None + enabled: bool = True + link_id: str = "" diff --git a/tests/compute/test_base_node.py b/tests/compute/test_base_node.py index 764af1e6e..b1393e25f 100644 --- a/tests/compute/test_base_node.py +++ b/tests/compute/test_base_node.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import os from collections import OrderedDict import pytest @@ -227,3 +228,64 @@ async def test_apply_markers_turns_disabled_filter_off(compute_project, manager) await node._ubridge_apply_markers("VPCS-10", nio) node._ubridge_send.assert_any_call("bridge enable_packet_filter VPCS-10 m off") assert node._marker_filter_bridges["m", "L1"] == "VPCS-10" + + +@pytest.mark.asyncio +async def test_delete_marker_capture_removes_pcap_and_entry(compute_project, manager): + # Deleting a marker's capture removes its pcap and forgets the bridge entry. + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + markers_dir = compute_project.markers_working_directory() + os.makedirs(markers_dir, exist_ok=True) + node._marker_filter_bridges["m", "L1"] = "VPCS-10" + pcap = os.path.join(markers_dir, f"{node.id}_L1_m.pcap") + open(pcap, "wb").write(b"data") + + await node.delete_marker_capture("m", "L1") + + assert not os.path.exists(pcap) + assert ("m", "L1") not in node._marker_filter_bridges + + +@pytest.mark.asyncio +async def test_delete_marker_capture_idempotent_when_missing(compute_project, manager): + # No file on disk → must not raise, and still clears the entry. + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + node._marker_filter_bridges["m", "L1"] = "VPCS-10" + + await node.delete_marker_capture("m", "L1") + + assert ("m", "L1") not in node._marker_filter_bridges + + +@pytest.mark.asyncio +async def test_delete_marker_capture_sends_delete_filter(compute_project, manager): + # With uBridge running, removing a marker issues a fine-grained + # delete_packet_filter (not a bridge-wide reset) so sibling pcaps stay open. + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + node._ubridge_send = AsyncioMagicMock() + node._ubridge_hypervisor = MagicMock() + node._ubridge_hypervisor.is_running.return_value = True + node._marker_filter_bridges["m", "L1"] = "VPCS-10" + + await node.delete_marker_capture("m", "L1") + + node._ubridge_send.assert_any_call("bridge delete_packet_filter VPCS-10 m") + assert ("m", "L1") not in node._marker_filter_bridges + + +@pytest.mark.asyncio +async def test_rebuild_marker_filter_delete_then_add(compute_project, manager): + # rebuild re-installs a single filter (delete_packet_filter + add) with the + # new params, no bridge reset; enabled=False turns it off after re-add. + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + node._ubridge_send = AsyncioMagicMock() + node._ubridge_hypervisor = MagicMock() + node._ubridge_hypervisor.is_running.return_value = True + node._marker_filter_bridges["m", "L1"] = "VPCS-10" + + await node.rebuild_marker_filter("m", "L1", "tcp", tag=7, direction="rx", enabled=False) + + cmds = [c.args[0] for c in node._ubridge_send.call_args_list] + assert any("delete_packet_filter VPCS-10 m" in c for c in cmds) + assert any("add_packet_filter VPCS-10 m mark" in c and "tcp" in c for c in cmds) + assert any("enable_packet_filter VPCS-10 m off" in c for c in cmds) diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py index 991fef5fa..78e4f6467 100644 --- a/tests/controller/test_marker.py +++ b/tests/controller/test_marker.py @@ -578,8 +578,9 @@ async def test_update_marker_enabled_only_hits_toggle_route(project): @pytest.mark.asyncio -async def test_update_marker_with_bpf_still_rebuilds_nio(project): - # A non-enabled-only change falls through to the NIO reset+reapply path. +async def test_update_marker_with_bpf_rebuilds_single_filter(project): + # A bpf change rebuilds just this marker's filter (delete + add), NOT a full + # NIO reapply, so sibling markers' pcaps stay open. with _valid_bpf(): link = await _make_link(project) node = link._nodes[0]["node"] @@ -588,7 +589,23 @@ async def test_update_marker_with_bpf_still_rebuilds_nio(project): compute.put.reset_mock() await link.update_marker("m", bpf="tcp") paths = [c.args[0] for c in compute.put.call_args_list] - assert any(p.endswith("/nio") for p in paths) + assert any(p.endswith("/markers/m/rebuild") for p in paths) + assert not any(p.endswith("/nio") for p in paths) # no full NIO reapply + + +@pytest.mark.asyncio +async def test_update_marker_ui_only_does_not_push(project): + # color/highlight_duration are UI-only — stored, never pushed to uBridge. + with _valid_bpf(): + link = await _make_link(project) + node = link._nodes[0]["node"] + await link.start_marker("m", "icmp") + compute = node.compute + compute.put.reset_mock() + await link.update_marker("m", color="#ffffff", highlight_duration=1500) + assert compute.put.call_args_list == [] # nothing pushed to uBridge + assert link.markers["m"]["color"] == "#ffffff" + assert link.markers["m"]["highlight_duration"] == 1500 # --------------------------------------------------------------------------- @@ -663,3 +680,18 @@ async def test_update_marker_definition_rejects_directional(project): await project.update_marker_definition("arp", color="#ffffff") await project.update_marker_definition("arp", direction=None) assert project.marker_definitions["arp"]["direction"] is None + + +@pytest.mark.asyncio +async def test_stop_marker_deletes_capture_pcap(project): + # Removing a marker asks the capture node's compute to delete its pcap, so + # the file is cleaned up even with the node stopped (the NIO reapply path + # only runs while uBridge is up). + with _valid_bpf(): + link = await _make_link(project) + capture = link._nodes[0]["node"] + await link.start_marker("icmp", "icmp", capture_node_id=capture.id) + capture.delete = AsyncioMagicMock() + await link.stop_marker("icmp") + + capture.delete.assert_called_once_with("/markers/icmp", params={"link_id": link.id}) From 95824b18611163a6d7d7581c3ab32f533b078d82 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 4 Aug 2026 22:19:43 +0800 Subject: [PATCH 22/36] marker: incremental apply, clear bridge map on uBridge stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _ubridge_apply_markers now installs only markers not already on the bridge (uBridge's reset_packet_filters preserves mark filters), so an NIO update no longer re-adds — and reopens — sibling markers' pcaps. _stop_ubridge clears _marker_filter_bridges so a node restart re-installs everything (the map would otherwise keep stale entries pointing at a fresh, empty uBridge). --- gns3server/compute/base_node.py | 23 +++++++++++++++++------ tests/compute/test_base_node.py | 26 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 7cdd15fce..fca52ce31 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -990,6 +990,10 @@ class BaseNode: log.info(f"Stopping uBridge hypervisor at {self._ubridge_hypervisor.endpoint}") await self._ubridge_hypervisor.stop() self._ubridge_hypervisor = None + # uBridge is gone, so every marker filter (and its in-bridge state) is + # gone too — clear the map so the next apply re-installs them all rather + # than skipping them as "already installed". + self._marker_filter_bridges.clear() async def add_ubridge_udp_connection(self, bridge_name, source_nio, destination_nio): """ @@ -1187,11 +1191,13 @@ class BaseNode: async def _ubridge_apply_markers(self, bridge_name, nio): """ - (Re-)apply every traffic-insight marker carried by *nio* to the uBridge - bridge *bridge_name*. Called from ``add_ubridge_udp_connection`` (bridge - creation / node restart) and ``update_ubridge_udp_connection`` (NIO update - — the preceding ``_ubridge_apply_filters`` has already issued - ``reset_packet_filters``, so we must re-add markers to survive the reset). + Install the traffic-insight markers carried by *nio* onto bridge + *bridge_name* that aren't already there. uBridge's ``reset_packet_filters`` + preserves mark filters (contract), so on an NIO update we add only the new + ones — re-adding an existing marker would either duplicate it or + close/reopen its pcap. Called from ``add_ubridge_udp_connection`` (fresh + bridge, empty map → installs all) and ``update_ubridge_udp_connection`` + (incremental). """ from gns3server.compute.marker.marker_manager import MarkerManager @@ -1202,9 +1208,14 @@ class BaseNode: manager = MarkerManager.instance() markers_dir = self.project.markers_working_directory() for name, spec in markers.items(): + link_id = spec.get("link_id", "") + # Incremental: skip markers already on this bridge. uBridge keeps mark + # filters across reset_packet_filters, so re-adding would duplicate (or + # reopen the pcap). A fresh bridge has an empty map → installs all. + if (name, link_id) in self._marker_filter_bridges: + continue bpf = spec.get("bpf", "") tag = spec.get("tag") - link_id = spec.get("link_id", "") pcap_path = os.path.join( markers_dir, f"{self._id}_{link_id}_{name}.pcap" ) diff --git a/tests/compute/test_base_node.py b/tests/compute/test_base_node.py index b1393e25f..2935e990b 100644 --- a/tests/compute/test_base_node.py +++ b/tests/compute/test_base_node.py @@ -289,3 +289,29 @@ async def test_rebuild_marker_filter_delete_then_add(compute_project, manager): assert any("delete_packet_filter VPCS-10 m" in c for c in cmds) assert any("add_packet_filter VPCS-10 m mark" in c and "tcp" in c for c in cmds) assert any("enable_packet_filter VPCS-10 m off" in c for c in cmds) + + +@pytest.mark.asyncio +async def test_apply_markers_skips_already_installed(compute_project, manager): + # Incremental apply: a marker already in _marker_filter_bridges is not + # re-added (uBridge keeps it across reset), so its pcap isn't reopened. + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + node._ubridge_send = AsyncioMagicMock() + node._marker_filter_bridges["m", "L1"] = "VPCS-10" # already installed + nio = NIOUDP(1234, "127.0.0.1", 4321) + nio.markers = {"m": {"bpf": "icmp", "tag": None, "link_id": "L1", "direction": None, "enabled": True}} + with patch("gns3server.compute.marker.marker_manager.MarkerManager") as mm: + mm.instance.return_value.register = MagicMock() + await node._ubridge_apply_markers("VPCS-10", nio) + cmds = [c.args[0] for c in node._ubridge_send.call_args_list] + assert not any("add_packet_filter" in c for c in cmds) # skipped, not re-added + + +@pytest.mark.asyncio +async def test_stop_ubridge_clears_marker_bridges(compute_project, manager): + # uBridge stopping drops every marker filter — the map must clear so the next + # apply re-installs them instead of skipping as "already installed". + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + node._marker_filter_bridges["m", "L1"] = "VPCS-10" + await node._stop_ubridge() + assert node._marker_filter_bridges == {} From 60e2bbbbbb61635c06689c3f6d4f7b00de0a9fa8 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 4 Aug 2026 22:59:12 +0800 Subject: [PATCH 23/36] marker: point directional defs at BPF, refresh implementation doc The 409 on a tx/rx definition now recommends encoding direction in the BPF (e.g. icmp[icmptype]==8) as the primary fix, with per-link markers as the single-link fallback. Doc updated: per-def rejects tx/rx (why + BPF), the pause section no longer claims bpf changes reset+reapply (they rebuild one filter), and a new Capture files section covers pcap cleanup + reset-preserves-mark. --- docs/features/marker-traffic-insight.md | 22 ++++++++++++++++++++-- gns3server/controller/project.py | 19 ++++++++++++------- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/docs/features/marker-traffic-insight.md b/docs/features/marker-traffic-insight.md index 1995b414d..e468e6faa 100644 --- a/docs/features/marker-traffic-insight.md +++ b/docs/features/marker-traffic-insight.md @@ -147,6 +147,13 @@ observer would silently flip the meaning of stored `direction`, so recreate the instead). It is not accepted on project-level definitions — a definition is link-agnostic and has no endpoints to choose from, so inherited markers always auto-pick per link. +For the same reason, a definition **rejects `direction: tx|rx`** (HTTP 409): each inherited +copy auto-picks its capture node, so a fixed tx/rx would denote different session directions +on different links. A definition is `both` only; encode the direction you want in the BPF +instead — e.g. `icmp and icmp[icmptype]==8` for echo requests, a packet-intrinsic property +that is consistent on every link regardless of capture node. tx/rx remains available on +per-link markers, where the capture node is fixed. + ## Pause & resume Two levels of silencing, both instant (no NIO rebuild, no pcap flush): @@ -156,8 +163,10 @@ Two levels of silencing, both instant (no NIO rebuild, no pcap flush): `enable_packet_filter … off`): no signal, no pcap, but traffic still relays — a paused `mark` is a no-op tap, not a drop. `{"enabled": true}` flips it back. A change to `enabled` alone is a single command (the pcap identity and emitted - counter are preserved); changing `bpf` or other fields still goes through a - reset+reapply. + counter are preserved). Changing `bpf`, `tag`, or `direction` rebuilds just that + one filter (`delete_packet_filter` + add) — only that marker's own pcap reopens + (a new capture session for the new BPF); changing `color`/`highlight_duration` + is UI-only, nothing is pushed to uBridge. - **Per-definition (inherited)** — `POST /v3/projects/{pid}/marker-definitions/{name}/pause` and `/resume` toggle **every** inherited `global-{name}` copy across all links at once (same `enable_packet_filter on|off`, fanned out per copy). Use to @@ -172,6 +181,15 @@ Two levels of silencing, both instant (no NIO rebuild, no pcap flush): | per-def `pause` (all `global-{name}` copies) | stop | stop | n/a | | per-def `resume` | resume | resume | n/a | +## Capture files + +Each marker appends matches to `/project-files/markers/__.pcap`. +Removing a marker — per-link `DELETE .../markers/{name}` or deleting a definition (which +removes every inherited copy) — deletes that marker's pcap too, even with the capture node +stopped (the filter is removed with `delete_packet_filter`, the file is unlinked). uBridge's +`reset_packet_filters` (run on NIO/filter changes) preserves mark filters, so unrelated +changes no longer close/reopen any marker's pcap. + ## API Endpoints All endpoints require a JWT bearer token (`POST /v3/access/users/authenticate`). The diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index bb3f5f103..8a2eb1590 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -967,17 +967,22 @@ class Project: def _validate_marker_definition_direction(self, name, direction): """ - Reject tx/rx on a marker definition: a definition auto-selects its - capture node per link (``_choose_marker_side``), while tx/rx is - interpreted from that node's perspective, so a fixed direction has no - stable meaning project-wide. Only 'both' (the default, = ``None``) is - allowed — use a per-link marker if a directional filter is needed. + Reject tx/rx on a marker definition: a definition fans out to every link + and auto-selects its capture node on each (``_choose_marker_side``), + while tx/rx is relative to that node, so a fixed direction has no + consistent meaning across links. Only 'both' (the default, = ``None``) + is allowed — encode the direction in the BPF instead (e.g. + ``icmp[icmptype]==8`` for echo requests), or use a per-link marker whose + capture node is pinned. """ if direction in ("tx", "rx"): raise ControllerError( f"Marker definition '{name}': direction '{direction}' is not allowed. " - "A definition auto-selects its capture node per link and tx/rx is " - "relative to that node — use 'both' (the default), or a per-link marker." + "A definition fans out to every link and auto-selects its capture node on each, " + "but tx/rx is relative to that node, so a fixed direction has no consistent " + "meaning across links. Keep 'both' (the default) and encode the direction in " + "the BPF instead, e.g. 'icmp and icmp[icmptype]==8' for echo requests only. " + "For a capture-node-relative direction on a single link, use a per-link marker." ) async def create_marker_definition(self, name, bpf, tag=None, direction=None, color=None, highlight_duration=None): From 1a0ce51f38be81b15c0ce7a27c83866540f436a0 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 4 Aug 2026 23:20:58 +0800 Subject: [PATCH 24/36] marker: validate def BPF once, skip re-validation on inherited fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A marker definition validated its BPF N times — once per link in the fan-out (start_marker runs validate_bpf_syntax on every copy), spawning one tcpdump -d subprocess per link for the same expression. Definitions did not validate BPF at all; only direction was checked. Move the single validation point to the definition layer (create/update), and validate each definition's BPF on project load (dropping any that have gone invalid, like private markers). The inherited fan-out (start_marker) and def sync (update_marker) now skip validate_bpf_syntax for inherited copies, since the BPF comes from an already-validated definition. Private per-link markers still validate inline as before. uBridge still runs pcap_compile at install, so an invalid expression can never slip through. Creating a definition over N links now runs one tcpdump instead of N. --- docs/features/marker-traffic-insight.md | 6 ++ gns3server/controller/project.py | 38 ++++++++++- gns3server/controller/udp_link.py | 22 +++++-- tests/controller/test_marker.py | 85 +++++++++++++++++++++++-- 4 files changed, 139 insertions(+), 12 deletions(-) diff --git a/docs/features/marker-traffic-insight.md b/docs/features/marker-traffic-insight.md index e468e6faa..04d638282 100644 --- a/docs/features/marker-traffic-insight.md +++ b/docs/features/marker-traffic-insight.md @@ -348,6 +348,12 @@ direction relative to the capture node; see [Direction](#direction). - **Render hints are not enforced.** `color` and `highlight_duration` (milliseconds, `>= 1`) are stored on the link and never sent to uBridge; `null` lets the UI apply its own default. A partial PUT (e.g. changing only `bpf`) leaves them untouched. +- **BPF is validated once per source.** A private per-link marker validates its BPF inline + on create/update. A definition validates its BPF once at create/update (and once per + definition on project load, dropping any whose BPF has gone invalid); the inherited + fan-out to every link then skips re-validation, so creating a definition over *N* links + 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 silently skipped by the inheritance fan-out. IOU uses one shared `IOL-BRIDGE` per node but diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 8a2eb1590..831fa1952 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -965,6 +965,21 @@ class Project: """ return self._marker_definitions + def _validate_marker_definition_bpf(self, name, bpf): + """ + Validate a marker definition's BPF once, here, so the fan-out to every + link (``_apply_def_to_all_links`` → ``inherit_marker`` → ``start_marker``) + and the per-link sync (``update_marker_definition`` → ``update_marker``) + can skip re-validation for the inherited copies — otherwise one + ``tcpdump -d`` subprocess runs per link for the same expression. A + private per-link marker still validates in ``start_marker``/``update_marker``. + """ + result = validate_bpf_syntax(bpf) + if not result.get("valid"): + raise ControllerError( + f"Marker definition '{name}': invalid BPF — {result.get('error', 'unknown error')}" + ) + def _validate_marker_definition_direction(self, name, direction): """ Reject tx/rx on a marker definition: a definition fans out to every link @@ -997,6 +1012,7 @@ class Project: f"Marker definition '{name}' already exists in this project" ) + self._validate_marker_definition_bpf(name, bpf) self._validate_marker_definition_direction(name, direction) self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "direction": direction, "color": color, "highlight_duration": highlight_duration, "paused": False} await self._apply_def_to_all_links(name) @@ -1015,6 +1031,7 @@ class Project: d = self._marker_definitions[name] if bpf is not None: + self._validate_marker_definition_bpf(name, bpf) d["bpf"] = bpf if tag is not None: d["tag"] = tag @@ -1489,10 +1506,27 @@ class Project: setattr(self, key, val) # marker_definitions is loaded separately (it is not a __init__ kwarg - # nor a simple attribute — it backs a read-only property). + # nor a simple attribute — it backs a read-only property). Each BPF + # is validated once here so the inherited fan-out (start_marker) can + # skip re-validation; an invalid definition is dropped with a warning + # rather than failing the open — it could not fan out anyway. defs = project_data.get("marker_definitions") if isinstance(defs, dict): - self._marker_definitions = defs + clean_defs = {} + for def_name, d in defs.items(): + bpf = d.get("bpf") + if not bpf: + log.warning("Dropping marker definition '%s' on load: missing bpf", def_name) + continue + result = validate_bpf_syntax(bpf) + if not result.get("valid"): + log.warning( + "Dropping marker definition '%s' on load: invalid BPF (%s)", + def_name, result.get("error") + ) + continue + clean_defs[def_name] = d + self._marker_definitions = clean_defs topology = project_data["topology"] for compute in topology.get("computes", []): diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 13c202751..0c5f2eeca 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -379,9 +379,15 @@ class UDPLink(Link): if name in self._markers: raise ControllerError(f"Marker '{name}' already exists on link {self._id}") - result = validate_bpf_syntax(bpf) - if not result.get("valid"): - raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}") + # Validate the BPF only for private per-link markers. An inherited copy + # (``inherited_from`` set) fans out from a definition whose BPF was + # already validated once at create/update (and on project load), so + # re-validating per link would spawn one ``tcpdump -d`` per link for the + # same expression. + if not inherited_from: + result = validate_bpf_syntax(bpf) + if not result.get("valid"): + raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}") if capture_node_id and not inherited_from: marker_side = self._node_by_id(capture_node_id) @@ -474,9 +480,13 @@ class UDPLink(Link): # Merge every changed field into the marker state first. if bpf is not None and bpf != marker_info["bpf"]: - result = validate_bpf_syntax(bpf) - if not result.get("valid"): - raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}") + # An inherited marker is synced from a definition whose BPF was + # already validated at create/update (or load); re-validating per + # link is redundant. Private markers validate here as before. + if not inherited: + result = validate_bpf_syntax(bpf) + if not result.get("valid"): + raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}") marker_info["bpf"] = bpf if tag is not None: marker_info["tag"] = tag diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py index 78e4f6467..9c13edbf8 100644 --- a/tests/controller/test_marker.py +++ b/tests/controller/test_marker.py @@ -28,6 +28,7 @@ import uuid import pytest from unittest.mock import MagicMock, patch +from contextlib import ExitStack from tests.utils import AsyncioMagicMock @@ -38,11 +39,20 @@ from gns3server.controller.controller_error import ControllerError, ControllerNo def _valid_bpf(): - """Bypass tcpdump-based BPF validation so tests don't depend on tcpdump.""" - return patch( + """Bypass tcpdump-based BPF validation so tests don't depend on tcpdump. + + Patches both namespaces that import ``validate_bpf_syntax`` by name: the + per-link ``udp_link`` (private marker create/update) and the project layer + (definition create/update/load), which is now the single validation point + for inherited copies. + """ + stack = ExitStack() + for target in ( "gns3server.controller.udp_link.validate_bpf_syntax", - return_value={"valid": True, "error": None}, - ) + "gns3server.controller.project.validate_bpf_syntax", + ): + stack.enter_context(patch(target, return_value={"valid": True, "error": None})) + return stack async def _make_link(project): @@ -438,6 +448,73 @@ async def test_apply_defs_to_new_link(project): assert new_link.markers["global-arp"]["inherited_from"] == "arp" +@pytest.mark.asyncio +async def test_create_marker_definition_validates_bpf_once(project): + # A definition validates its BPF once (project layer); the inherited fan-out + # to every link must NOT re-validate — no tcpdump subprocess per link. + with patch("gns3server.controller.project.validate_bpf_syntax", + return_value={"valid": True, "error": None}) as proj_val, \ + patch("gns3server.controller.udp_link.validate_bpf_syntax", + return_value={"valid": True, "error": None}) as link_val: + await _make_link(project) + await _make_link(project) + await project.create_marker_definition("arp", "arp") + + assert proj_val.call_count == 1 # validated once at the def layer + assert link_val.call_count == 0 # fan-out skipped per-link validation + + +@pytest.mark.asyncio +async def test_create_marker_definition_rejects_invalid_bpf(project): + + with patch("gns3server.controller.project.validate_bpf_syntax", + return_value={"valid": False, "error": "syntax error"}): + with pytest.raises(ControllerError): + await project.create_marker_definition("arp", "not a real bpf") + assert "arp" not in project.marker_definitions + + +@pytest.mark.asyncio +async def test_update_marker_definition_skips_per_link_validation(project): + # Updating a def's BPF validates once more (project); the per-link sync + # (update_marker with inherited=True) must NOT re-validate. + with patch("gns3server.controller.project.validate_bpf_syntax", + return_value={"valid": True, "error": None}) as proj_val, \ + patch("gns3server.controller.udp_link.validate_bpf_syntax", + return_value={"valid": True, "error": None}) as link_val: + await _make_link(project) + await _make_link(project) + await project.create_marker_definition("arp", "arp") + await project.update_marker_definition("arp", bpf="arp or rarp") + + assert proj_val.call_count == 2 # once on create, once on update + assert link_val.call_count == 0 # sync skipped per-link validation + + +@pytest.mark.asyncio +async def test_start_marker_skips_validation_for_inherited(project): + # An inherited marker rides an already-validated definition BPF, so + # start_marker must not call validate_bpf_syntax. + with patch("gns3server.controller.udp_link.validate_bpf_syntax", + return_value={"valid": True, "error": None}) as link_val: + link = await _make_link(project) + await link.inherit_marker("arp", {"bpf": "arp"}) + + assert link_val.call_count == 0 + assert link.markers["global-arp"]["bpf"] == "arp" + + +@pytest.mark.asyncio +async def test_start_marker_validates_for_private(project): + # A private (non-inherited) marker still validates inline. + with patch("gns3server.controller.udp_link.validate_bpf_syntax", + return_value={"valid": True, "error": None}) as link_val: + link = await _make_link(project) + await link.start_marker("icmp", "icmp") + + assert link_val.call_count == 1 + + @pytest.mark.asyncio async def test_markers_aggregation(project): From 75f278228b38b0ae6fb2adb7a97ec272b8cfeb21 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 5 Aug 2026 00:08:12 +0800 Subject: [PATCH 25/36] marker: drop deleted marker from port NIO cache to stop empty pcap on restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a marker while its node was stopped, then starting the node, recreated an empty pcap. Root cause: delete_marker_capture removed the uBridge filter and the pcap file but not the marker spec cached on the port NIO (nio.markers) — the data source _ubridge_apply_markers reads on node start. The stale spec reinstalled the marker when uBridge came up. This was a regression from switching stop_marker off update() (which re-sent the NIO and implicitly refreshed nio.markers) to the fine-grained node.delete(/markers/{name}) path. Fix: make the delete port-aware so the compute can locate the NIO. The DELETE marker route becomes /adapters/{a}/ports/{p}/markers/{name} across all six node types; the handler resolves the NIO via get_nio and passes it to delete_marker_capture, which now pops the marker from nio.markers. get_nio works regardless of uBridge state, so the stopped-node case is covered. The controller's stop_marker targets the capture side's adapter/port. --- gns3server/api/routes/compute/cloud_nodes.py | 12 +++++++++--- gns3server/api/routes/compute/docker_nodes.py | 11 ++++++++--- gns3server/api/routes/compute/dynamips_nodes.py | 11 ++++++++--- gns3server/api/routes/compute/iou_nodes.py | 11 ++++++++--- gns3server/api/routes/compute/qemu_nodes.py | 11 ++++++++--- gns3server/api/routes/compute/vpcs_nodes.py | 12 +++++++++--- gns3server/compute/base_node.py | 10 +++++++++- gns3server/controller/udp_link.py | 5 ++++- tests/compute/test_base_node.py | 14 ++++++++++++++ tests/controller/test_marker.py | 4 +++- 10 files changed, 80 insertions(+), 21 deletions(-) diff --git a/gns3server/api/routes/compute/cloud_nodes.py b/gns3server/api/routes/compute/cloud_nodes.py index d33023053..a5f2bb6a9 100644 --- a/gns3server/api/routes/compute/cloud_nodes.py +++ b/gns3server/api/routes/compute/cloud_nodes.py @@ -297,20 +297,26 @@ async def resume_cloud_markers(node: Cloud = Depends(dep_node)) -> None: @router.delete( - "/{node_id}/markers/{marker_name}", + "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", status_code=status.HTTP_204_NO_CONTENT ) async def delete_cloud_marker_capture( + *, marker_name: str, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, link_id: str = "", node: Cloud = 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 node stopped. + removed) so the file is cleaned up even with the node stopped. Also drops + the marker from the port NIO's cached spec so a node restart won't reinstall + it (and recreate an empty pcap). """ - await node.delete_marker_capture(marker_name, link_id) + nio = node.get_nio(port_number) + await node.delete_marker_capture(marker_name, link_id, nio) @router.put("/{node_id}/markers/{marker_name}/rebuild") diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index a5a797972..f01425565 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -453,21 +453,26 @@ async def resume_docker_markers(node: DockerVM = Depends(dep_node)) -> None: @router.delete( - "/{node_id}/markers/{marker_name}", + "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def delete_docker_marker_capture( marker_name: str, + adapter_number: int, + port_number: int, link_id: str = "", node: DockerVM = 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 node stopped. + removed) so the file is cleaned up even with the node stopped. Also drops + the marker from the port NIO's cached spec so a node restart won't reinstall + it (and recreate an empty pcap). """ - await node.delete_marker_capture(marker_name, link_id) + nio = node.get_nio(adapter_number) + await node.delete_marker_capture(marker_name, link_id, nio) @router.put( diff --git a/gns3server/api/routes/compute/dynamips_nodes.py b/gns3server/api/routes/compute/dynamips_nodes.py index 863f192ab..c60786f1b 100644 --- a/gns3server/api/routes/compute/dynamips_nodes.py +++ b/gns3server/api/routes/compute/dynamips_nodes.py @@ -412,21 +412,26 @@ async def resume_dynamips_markers(node: Router = Depends(dep_node)) -> None: @router.delete( - "/{node_id}/markers/{marker_name}", + "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def delete_dynamips_marker_capture( marker_name: str, + adapter_number: int, + port_number: int, link_id: str = "", node: Router = 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 node stopped. + removed) so the file is cleaned up even with the node stopped. Also drops + the marker from the port NIO's cached spec so a node restart won't reinstall + it (and recreate an empty pcap). """ - await node.delete_marker_capture(marker_name, link_id) + nio = node.get_nio(adapter_number, port_number) + await node.delete_marker_capture(marker_name, link_id, nio) @router.put( diff --git a/gns3server/api/routes/compute/iou_nodes.py b/gns3server/api/routes/compute/iou_nodes.py index 73d78a54e..76ee8c594 100644 --- a/gns3server/api/routes/compute/iou_nodes.py +++ b/gns3server/api/routes/compute/iou_nodes.py @@ -391,21 +391,26 @@ async def resume_iou_markers(node: IOUVM = Depends(dep_node)) -> None: @router.delete( - "/{node_id}/markers/{marker_name}", + "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def delete_iou_marker_capture( marker_name: str, + adapter_number: int, + port_number: int, link_id: str = "", node: IOUVM = 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 node stopped. + removed) so the file is cleaned up even with the node stopped. Also drops + the marker from the port NIO's cached spec so a node restart won't reinstall + it (and recreate an empty pcap). """ - await node.delete_marker_capture(marker_name, link_id) + nio = node.get_nio(adapter_number, port_number) + await node.delete_marker_capture(marker_name, link_id, nio) @router.put( diff --git a/gns3server/api/routes/compute/qemu_nodes.py b/gns3server/api/routes/compute/qemu_nodes.py index aa353bc5f..9494d6044 100644 --- a/gns3server/api/routes/compute/qemu_nodes.py +++ b/gns3server/api/routes/compute/qemu_nodes.py @@ -483,21 +483,26 @@ async def resume_qemu_markers(node: QemuVM = Depends(dep_node)) -> None: @router.delete( - "/{node_id}/markers/{marker_name}", + "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def delete_qemu_marker_capture( marker_name: str, + adapter_number: int, + port_number: int = Path(..., ge=0, le=0), link_id: str = "", node: QemuVM = 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 node stopped. + removed) so the file is cleaned up even with the node stopped. Also drops + the marker from the port NIO's cached spec so a node restart won't reinstall + it (and recreate an empty pcap). """ - await node.delete_marker_capture(marker_name, link_id) + nio = node.get_nio(adapter_number) + await node.delete_marker_capture(marker_name, link_id, nio) @router.put( diff --git a/gns3server/api/routes/compute/vpcs_nodes.py b/gns3server/api/routes/compute/vpcs_nodes.py index 251e18e16..546fabdb0 100644 --- a/gns3server/api/routes/compute/vpcs_nodes.py +++ b/gns3server/api/routes/compute/vpcs_nodes.py @@ -390,21 +390,27 @@ async def resume_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None: @router.delete( - "/{node_id}/markers/{marker_name}", + "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def delete_vpcs_marker_capture( + *, marker_name: str, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, link_id: str = "", node: VPCSVM = 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 node stopped. + removed) so the file is cleaned up even with the node stopped. Also drops + the marker from the port NIO's cached spec so a node restart won't reinstall + it (and recreate an empty pcap). """ - await node.delete_marker_capture(marker_name, link_id) + nio = node.get_nio(port_number) + await node.delete_marker_capture(marker_name, link_id, nio) @router.put( diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index fca52ce31..76d543364 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1134,7 +1134,7 @@ class BaseNode: # bad expression must surface instead of being silently dropped. await self._ubridge_send(cmd) - async def delete_marker_capture(self, name, link_id): + async def delete_marker_capture(self, name, link_id, nio=None): """ Remove a marker from uBridge (fine-grained ``delete_packet_filter`` — NOT reset_packet_filters, so sibling markers' pcaps aren't closed/reopened) @@ -1142,7 +1142,15 @@ class BaseNode: removed; safe with the node stopped (filter removal is skipped, the file is still unlinked). IOU overrides ``_ubridge_delete_marker_filter`` for its ``iol_bridge`` command shape. + + ``nio`` is the port NIO whose cached ``nio.markers`` carries this marker + spec; it is dropped here so a later node start / NIO reapply + (``_ubridge_apply_markers``) does not reinstall the marker. Without this, + deleting a marker while the node is stopped left the spec in + ``nio.markers``, and starting the node recreated an empty pcap. """ + if nio is not None and getattr(nio, "markers", None): + nio.markers.pop(name, None) bridge_name = self._marker_filter_bridges.pop((name, link_id), None) if bridge_name is not None: await self._ubridge_delete_marker_filter(bridge_name, name) diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 0c5f2eeca..68b06b27f 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -444,7 +444,10 @@ class UDPLink(Link): side = next((s for s in self._nodes if str(s["node"].id) == str(capture_node_id)), None) if side is not None: try: - await side["node"].delete(f"/markers/{name}", params={"link_id": self._id}) + await side["node"].delete( + f"/adapters/{side['adapter_number']}/ports/{side['port_number']}/markers/{name}", + params={"link_id": self._id}, + ) except Exception: pass # best-effort: old compute without the route leaves the file self._project.emit_notification("link.updated", self.asdict()) diff --git a/tests/compute/test_base_node.py b/tests/compute/test_base_node.py index 2935e990b..3eaadb9f9 100644 --- a/tests/compute/test_base_node.py +++ b/tests/compute/test_base_node.py @@ -273,6 +273,20 @@ async def test_delete_marker_capture_sends_delete_filter(compute_project, manage assert ("m", "L1") not in node._marker_filter_bridges +@pytest.mark.asyncio +async def test_delete_marker_capture_drops_from_nio_markers(compute_project, manager): + # The marker spec cached on the port NIO (nio.markers) is what + # _ubridge_apply_markers reads on node start. delete_marker_capture must drop + # it, else deleting a marker while the node is stopped leaves the spec in + # nio.markers and starting the node reinstalls it (empty pcap reappears). + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + nio = NIOUDP(1234, "127.0.0.1", 4321) + nio.markers = {"m": {"bpf": "icmp", "tag": None, "link_id": "L1", + "direction": None, "enabled": True}} + await node.delete_marker_capture("m", "L1", nio) + assert "m" not in nio.markers + + @pytest.mark.asyncio async def test_rebuild_marker_filter_delete_then_add(compute_project, manager): # rebuild re-installs a single filter (delete_packet_filter + add) with the diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py index 9c13edbf8..d57157f2d 100644 --- a/tests/controller/test_marker.py +++ b/tests/controller/test_marker.py @@ -771,4 +771,6 @@ async def test_stop_marker_deletes_capture_pcap(project): capture.delete = AsyncioMagicMock() await link.stop_marker("icmp") - capture.delete.assert_called_once_with("/markers/icmp", params={"link_id": link.id}) + capture.delete.assert_called_once_with( + "/adapters/0/ports/0/markers/icmp", params={"link_id": link.id} + ) From f7b19dae99359e817f98826ac2f84ba8bffc7e45 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 5 Aug 2026 00:49:37 +0800 Subject: [PATCH 26/36] marker: tighten marker name max length from 128 to 32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 128 characters was far beyond any realistic marker label (icmp, arp, tcp-syn) and would have collided with the pcap filename budget once a tag prefix is added later. Cap the user-facing name at 32 in both MarkerCreate and MarkerDefinitionCreate; the compute-side name guard now also rejects names longer than 48, which covers the `global-{def_name}` inherited form (≤ 39). --- docs/features/marker-traffic-insight.md | 2 ++ gns3server/compute/base_node.py | 5 ++++- gns3server/schemas/controller/links.py | 4 ++-- tests/api/routes/controller/test_markers.py | 11 +++++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/features/marker-traffic-insight.md b/docs/features/marker-traffic-insight.md index 04d638282..b275c02b7 100644 --- a/docs/features/marker-traffic-insight.md +++ b/docs/features/marker-traffic-insight.md @@ -340,6 +340,8 @@ direction relative to the capture node; see [Direction](#direction). filter, the pcap filename, and `MARK` signal routing — so rename is a delete + recreate, not a field update. PUT ignores the body `name`; the `{name}` path parameter identifies the target, and only `bpf / tag / color / enabled / highlight_duration` are changeable. + Names are 1–32 chars (`[A-Za-z0-9][A-Za-z0-9_.-]*`); inherited copies carry a `global-` + prefix, so their filter names reach ~39. - **`global` prefix reserved.** User-chosen names may not start with `global`; inherited markers are stored as `global-{definition_name}` so the two namespaces cannot collide. Omitting `name` on create yields an auto-generated, prefix-free name. diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 76d543364..aaf3da9a1 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1115,7 +1115,10 @@ class BaseNode: # marker definitions (inherit_marker). The prefix is only forbidden at the # user-facing schema layer, not at the uBridge boundary. _MARKER_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") - if not _MARKER_NAME_RE.match(name): + # Defense-in-depth vs hand-edited topology: the user-facing name is capped + # at 32 by the schema; inherited copies carry a ``global-`` prefix (≤ 39), + # so allow up to 48 here. + if not _MARKER_NAME_RE.match(name) or len(name) > 48: raise UbridgeError(f"Invalid marker name: {name!r}") cmd = 'bridge add_packet_filter {bridge} {name} mark "{bpf}"'.format( bridge=bridge_name, name=name, bpf=bpf diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index dd4b4ce5c..853293f8c 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -152,7 +152,7 @@ class MarkerCreate(BaseModel): name: Optional[str] = Field( None, pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$", - max_length=128, + max_length=32, description='Unique marker name on the link. Auto-generated when absent.', ) bpf: str @@ -236,7 +236,7 @@ class MarkerDefinitionCreate(BaseModel): name: Optional[str] = Field( None, pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$", - max_length=128, + max_length=32, description="Unique definition name. Auto-generated when absent.", ) bpf: str diff --git a/tests/api/routes/controller/test_markers.py b/tests/api/routes/controller/test_markers.py index d9fc27b3b..53e57e7d9 100644 --- a/tests/api/routes/controller/test_markers.py +++ b/tests/api/routes/controller/test_markers.py @@ -105,6 +105,17 @@ class TestMarkerRoutes: ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + async def test_create_marker_name_too_long_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = UDPLink(project) + project._links = {link.id: link} + + response = await client.post( + app.url_path_for("create_marker", project_id=project.id, link_id=link.id), + json={"name": "x" * 33, "bpf": "icmp"}, # max_length is 32 + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + async def test_get_markers(self, app: FastAPI, client: AsyncClient, project: Project) -> None: link = UDPLink(project) From 7ed4eeab298142fb0675e7790f51da8f9941bebc Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 5 Aug 2026 01:40:22 +0800 Subject: [PATCH 27/36] mcp: drop direction from marker_definition tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A marker definition fans out to every link and auto-selects its capture node on each, so tx/rx is relative to a node that varies per link — the controller already rejects it (409). Exposing direction on the MCP definition tool let an agent ask for something that could only fail. Remove the parameter and the handler's direction handling; the docstring now points to encoding direction in the BPF (e.g. 'icmp and icmp[icmptype]==8'). Per-link link_marker keeps direction, where the capture node is fixed. --- gns3server/api/routes/mcp/__init__.py | 11 ++++-- gns3server/api/routes/mcp/links.py | 14 ++----- tests/api/routes/mcp/test_handlers.py | 55 +++++++++++++-------------- 3 files changed, 39 insertions(+), 41 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 3f2425b00..2369fe658 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -1052,7 +1052,6 @@ async def marker_definition( tag: Annotated[int | None, Field(description="Numeric tag for packet correlation")] = None, color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None, highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None, - direction: Annotated[str | None, Field(description="Direction filter: 'tx' (capture node sending only), 'rx' (receiving only), or 'both' (no filter — on update this clears a previously set direction). Omit to leave unchanged on update.")] = None, ) -> list[dict[str, Any]]: """Manage project-level marker definitions — traffic-insight rules that apply to ALL links. @@ -1061,14 +1060,20 @@ async def marker_definition( On delete, 'global-{name}' is removed from every link. Create requires: project_id, action='create', bpf - Update requires: project_id, action='update', def_name, and at least one of (bpf, tag, direction, color, highlight_duration) + Update requires: project_id, action='update', def_name, and at least one of (bpf, tag, color, highlight_duration) Delete requires: project_id, action='delete', def_name List requires: project_id, action='list' + A definition has NO direction (tx/rx): it fans out to every link and auto-selects + its capture node on each, so a fixed direction has no consistent meaning. Encode + the direction you want in the BPF instead (e.g. 'icmp and icmp[icmptype]==8' for + echo requests only). For a capture-node-relative direction on a single link, use + the per-link `link_marker` tool. + Common BPF examples: 'arp', 'icmp', 'ospf', 'tcp port 22', 'udp port 53' """ params = {"project_id": project_id, "action": action} - for opt in ("bpf", "def_name", "name", "tag", "direction", "color", "highlight_duration"): + for opt in ("bpf", "def_name", "name", "tag", "color", "highlight_duration"): val = locals().get(opt) if val is not None: params[opt] = val diff --git a/gns3server/api/routes/mcp/links.py b/gns3server/api/routes/mcp/links.py index 9ddf2b377..7e1afc6a2 100644 --- a/gns3server/api/routes/mcp/links.py +++ b/gns3server/api/routes/mcp/links.py @@ -460,9 +460,9 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) for opt in ("name", "tag", "color", "highlight_duration"): if params.get(opt) is not None: body[opt] = params[opt] - # direction: "tx"/"rx" set a one-way filter; "both"/omitted = no filter. - if params.get("direction") in ("tx", "rx"): - body["direction"] = params["direction"] + # No direction: a definition fans out to every link and auto-selects its + # capture node on each, so tx/rx (which is relative to that node) has no + # consistent meaning. Encode direction in the BPF instead. return conn.http_call("post", base, json_data=body).json() def_name = params.get("def_name") @@ -476,14 +476,8 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) for opt in ("bpf", "tag", "color", "highlight_duration"): if params.get(opt) is not None: body[opt] = params[opt] - # direction tri-state: omitted=preserve, "tx"/"rx"=set, "both"=clear (→ null). - direction = params.get("direction") - if direction == "both": - body["direction"] = None - elif direction in ("tx", "rx"): - body["direction"] = direction if not body: - return {"error": "At least one update field is required (bpf, tag, direction, color, highlight_duration)"} + return {"error": "At least one update field is required (bpf, tag, color, highlight_duration)"} return conn.http_call("put", url, json_data=body).json() # action == "delete" diff --git a/tests/api/routes/mcp/test_handlers.py b/tests/api/routes/mcp/test_handlers.py index 5cd8a8bc0..fc8cd00d2 100644 --- a/tests/api/routes/mcp/test_handlers.py +++ b/tests/api/routes/mcp/test_handlers.py @@ -441,39 +441,44 @@ class TestLinkMarker: class TestMarkerDefinition: - """marker_definition_handler direction tri-state (same semantics as link markers).""" + """marker_definition_handler build create/update bodies. + + A definition has NO direction: it fans out to every link and auto-selects its + capture node on each, so tx/rx (relative to that node) has no consistent + meaning — any direction passed is ignored, never reaching the request body. + """ mod = "links" - def test_update_direction_both_clears(self, ctx): + def test_create_builds_body(self, ctx): from gns3server.api.routes.mcp.links import marker_definition_handler with patch(f"{BASE}.{self.mod}._get_connector") as m: conn = _mock_conn({"name": "arp"}) m.return_value = conn marker_definition_handler( - {"project_id": "p", "action": "update", - "def_name": "arp", "direction": "both"}, ctx, + {"project_id": "p", "action": "create", + "bpf": "arp", "tag": 1, "color": "#fff"}, ctx, ) conn.http_call.assert_called_with( - "put", "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp", - json_data={"direction": None}, + "post", "http://192.168.1.3:3080/v3/projects/p/marker-definitions", + json_data={"bpf": "arp", "tag": 1, "color": "#fff"}, ) - def test_update_direction_tx_sets(self, ctx): + def test_create_ignores_direction(self, ctx): from gns3server.api.routes.mcp.links import marker_definition_handler with patch(f"{BASE}.{self.mod}._get_connector") as m: conn = _mock_conn({"name": "arp"}) m.return_value = conn marker_definition_handler( - {"project_id": "p", "action": "update", - "def_name": "arp", "direction": "tx"}, ctx, + {"project_id": "p", "action": "create", + "bpf": "arp", "direction": "tx"}, ctx, ) conn.http_call.assert_called_with( - "put", "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp", - json_data={"direction": "tx"}, + "post", "http://192.168.1.3:3080/v3/projects/p/marker-definitions", + json_data={"bpf": "arp"}, ) - def test_update_direction_omitted_preserved(self, ctx): + def test_update_builds_body(self, ctx): from gns3server.api.routes.mcp.links import marker_definition_handler with patch(f"{BASE}.{self.mod}._get_connector") as m: conn = _mock_conn({"name": "arp"}) @@ -487,30 +492,24 @@ class TestMarkerDefinition: json_data={"tag": 1}, ) - def test_create_direction_both_omitted(self, ctx): + def test_update_ignores_direction(self, ctx): from gns3server.api.routes.mcp.links import marker_definition_handler with patch(f"{BASE}.{self.mod}._get_connector") as m: conn = _mock_conn({"name": "arp"}) m.return_value = conn marker_definition_handler( - {"project_id": "p", "action": "create", - "bpf": "arp", "direction": "both"}, ctx, + {"project_id": "p", "action": "update", + "def_name": "arp", "tag": 1, "direction": "rx"}, ctx, ) conn.http_call.assert_called_with( - "post", "http://192.168.1.3:3080/v3/projects/p/marker-definitions", - json_data={"bpf": "arp"}, + "put", "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp", + json_data={"tag": 1}, ) - def test_create_direction_tx(self, ctx): + def test_update_requires_a_field(self, ctx): from gns3server.api.routes.mcp.links import marker_definition_handler - with patch(f"{BASE}.{self.mod}._get_connector") as m: - conn = _mock_conn({"name": "arp"}) - m.return_value = conn - marker_definition_handler( - {"project_id": "p", "action": "create", - "bpf": "arp", "direction": "tx"}, ctx, - ) - conn.http_call.assert_called_with( - "post", "http://192.168.1.3:3080/v3/projects/p/marker-definitions", - json_data={"bpf": "arp", "direction": "tx"}, + with patch(f"{BASE}.{self.mod}._get_connector"): + result = marker_definition_handler( + {"project_id": "p", "action": "update", "def_name": "arp"}, ctx, ) + assert "error" in result From 29c1b040783d3cfcdef40c94d2cacd42dd19e006 Mon Sep 17 00:00:00 2001 From: grossmj Date: Wed, 5 Aug 2026 21:55:23 +0200 Subject: [PATCH 28/36] fix: add missing HTTPException import --- gns3server/api/routes/compute/docker_nodes.py | 2 +- gns3server/api/routes/compute/dynamips_nodes.py | 2 +- gns3server/api/routes/compute/qemu_nodes.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index f01425565..86ff4d84d 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -20,7 +20,7 @@ API routes for Docker nodes. import os -from fastapi import APIRouter, WebSocket, Depends, Body, status +from fastapi import APIRouter, WebSocket, Depends, Body, status, HTTPException from fastapi.encoders import jsonable_encoder from fastapi.responses import StreamingResponse from uuid import UUID diff --git a/gns3server/api/routes/compute/dynamips_nodes.py b/gns3server/api/routes/compute/dynamips_nodes.py index c60786f1b..744dfa3ba 100644 --- a/gns3server/api/routes/compute/dynamips_nodes.py +++ b/gns3server/api/routes/compute/dynamips_nodes.py @@ -20,7 +20,7 @@ API routes for Dynamips nodes. import os -from fastapi import APIRouter, WebSocket, Body, Depends, status +from fastapi import APIRouter, WebSocket, Body, Depends, status, HTTPException from fastapi.encoders import jsonable_encoder from fastapi.responses import StreamingResponse from typing import List, Union diff --git a/gns3server/api/routes/compute/qemu_nodes.py b/gns3server/api/routes/compute/qemu_nodes.py index 9494d6044..51f8f60d8 100644 --- a/gns3server/api/routes/compute/qemu_nodes.py +++ b/gns3server/api/routes/compute/qemu_nodes.py @@ -20,7 +20,7 @@ API routes for Qemu nodes. import os -from fastapi import APIRouter, WebSocket, Depends, Body, Path, status +from fastapi import APIRouter, WebSocket, Depends, Body, Path, status, HTTPException from fastapi.encoders import jsonable_encoder from fastapi.responses import StreamingResponse from typing import Union From b8ac76f047f7ae940f20f3b875b728e64353616f Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 6 Aug 2026 21:33:34 +0800 Subject: [PATCH 29/36] ubridge: require version >= 1.2.0 Drop the platform-specific floor (0.9.12 darwin / 0.9.14 others) in favour of a single 1.2.0 minimum. This server now relies on features only present in recent uBridge builds: the AF_UNIX control channel (-U / SO_PEERCRED), the marker (mark) filter, and the brctl-backed builtin Ethernet Switch. Removes the now-unused sys import left behind by the dropped darwin branch (target is Linux-only). --- gns3server/compute/ubridge/hypervisor.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/gns3server/compute/ubridge/hypervisor.py b/gns3server/compute/ubridge/hypervisor.py index ae8334662..89d815100 100644 --- a/gns3server/compute/ubridge/hypervisor.py +++ b/gns3server/compute/ubridge/hypervisor.py @@ -18,7 +18,6 @@ Represents a uBridge hypervisor and starts/stops the associated uBridge process. """ -import sys import os import socket import subprocess @@ -154,19 +153,17 @@ class Hypervisor(UBridgeHypervisor): async def _check_ubridge_version(self, env=None): """ - Checks if the ubridge executable version + Checks if the ubridge executable version meets the minimum required. """ try: output = await subprocess_check_output(self._path, "-v", cwd=self._working_dir, env=env) match = re.search(r"ubridge version ([0-9a-z\.]+)", output) if match: self._version = match.group(1) - if sys.platform.startswith("darwin"): - minimum_required_version = "0.9.12" - else: - # uBridge version 0.9.14 is required for packet filters - # to work for IOU nodes. - minimum_required_version = "0.9.14" + # uBridge >= 1.2.0 is required for features this server now + # relies on: the AF_UNIX control channel (-U), the marker + # (mark) filter, and the brctl-backed builtin Ethernet Switch. + minimum_required_version = "1.2.0" if parse_version(self._version) < parse_version(minimum_required_version): raise UbridgeError(f"uBridge executable version must be >= {minimum_required_version}") else: From 5f4ac38434e1c6e5873be357aa6caa6213fe2014 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 6 Aug 2026 21:33:38 +0800 Subject: [PATCH 30/36] ubridge: default the control transport to unix Switch ubridge_control_transport from tcp (-H) to unix (-U), the AF_UNIX + SO_PEERCRED channel recommended on Linux for kernel-level peer authentication. tcp is retained as an opt-in for backward compatibility. Existing deployments that set the key explicitly are unaffected; only fresh installs / unset keys pick up the new default. --- gns3server/config_samples/gns3_server.conf | 9 +++++---- gns3server/schemas/config.py | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/gns3server/config_samples/gns3_server.conf b/gns3server/config_samples/gns3_server.conf index 04ed81e85..50a6ca01a 100644 --- a/gns3server/config_samples/gns3_server.conf +++ b/gns3server/config_samples/gns3_server.conf @@ -92,10 +92,11 @@ udp_end_port_range = 30000 ; uBridge executable location, default: search in PATH ;ubridge_path = ubridge -; uBridge control channel transport: "tcp" (-H host:port, default) or "unix" -; (-U socket_path; AF_UNIX + SO_PEERCRED, recommended on Linux for kernel-level -; peer authentication). TCP now binds loopback by default. -;ubridge_control_transport = tcp +; uBridge control channel transport: "unix" (-U socket_path; AF_UNIX + +; SO_PEERCRED, default — recommended on Linux for kernel-level peer +; authentication) or "tcp" (-H host:port; retained for backward compatibility, +; binds loopback). +;ubridge_control_transport = unix ; Marker (traffic-insight) UDP sink: one listener per compute process that ; receives uBridge MARK signals from every uBridge on this host. diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index af920c099..0c712b351 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -164,10 +164,11 @@ class ServerSettings(BaseModel): udp_start_port_range: int = Field(10000, gt=0, le=65535) udp_end_port_range: int = Field(30000, gt=0, le=65535) ubridge_path: str = "ubridge" - # Transport for the uBridge hypervisor control channel. "tcp" (-H) is the - # historical default; "unix" (-U, AF_UNIX + SO_PEERCRED) is recommended on - # Linux for kernel-level peer authentication. - ubridge_control_transport: UbridgeControlTransport = UbridgeControlTransport.tcp + # Transport for the uBridge hypervisor control channel. "unix" (-U, + # AF_UNIX + SO_PEERCRED) is the default — recommended on Linux for + # kernel-level peer authentication. "tcp" (-H) is retained for backward + # compatibility. + ubridge_control_transport: UbridgeControlTransport = UbridgeControlTransport.unix # Marker (traffic-insight) UDP sink: one listener per compute process that # receives ubridge MARK signals from every ubridge on this host. The host # defaults to loopback because ubridge runs on the same host as the compute. From 33222336587b4ae9a60e275abeaa6a573a2e8e17 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 7 Aug 2026 01:53:33 +0800 Subject: [PATCH 31/36] marker: serial-link (WAN) support via data_link_type -> uBridge linktype Markers now work on serial links (Cisco HDLC / PPP / Frame Relay / ATM), not just Ethernet. A marker carries a data_link_type (default DLT_EN10MB); at the uBridge boundary it becomes the 'mark ... linktype ' keyword so the BPF compiles and the pcap is written with the matching link-layer. - MarkerCreate / MarkerDefinitionCreate gain data_link_type (default DLT_EN10MB). Per-link it is create-only; definitions are updatable (a change re-fans-out). - base_node._marker_linktype() normalizes the GNS3 DLT name (strip DLT_, uppercase, None for EN10MB). Single source is SerialPort.data_link_types, so Cisco PPP -> PPP_SERIAL (50), matching the capture path -- no second mapping table. - _ubridge_add_marker_filter (generic) and the IOU marker loop append 'linktype '. - Definition fan-out branches on link_type in inherit_marker: Ethernet is always EN10MB; a serial link uses the definition's WAN encapsulation, or is SKIPPED when none was chosen (an EN10MB pcap on serial is undecodable). One definition covers a mixed topology. - MCP marker_definition exposes data_link_type (None = not forwarded). - No uBridge rebuild on a data_link_type change -- only that one marker's filter is swapped (delete + re-add), mirroring a BPF change; reset_packet_filters preserves sibling mark filters. Requires the uBridge build with 'mark ... linktype' support. --- gns3server/api/routes/controller/projects.py | 2 + gns3server/api/routes/mcp/__init__.py | 5 +- gns3server/api/routes/mcp/links.py | 6 +- gns3server/compute/base_node.py | 26 ++++++- gns3server/compute/iou/iou_vm.py | 3 + gns3server/controller/link.py | 14 ++++ gns3server/controller/project.py | 40 ++++++++--- gns3server/controller/udp_link.py | 18 +++-- gns3server/schemas/controller/links.py | 24 +++++++ tests/compute/test_base_node.py | 46 ++++++++++++ tests/controller/test_marker.py | 76 ++++++++++++++++++-- 11 files changed, 231 insertions(+), 29 deletions(-) diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index c9c1c0555..f9c838a34 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -271,6 +271,7 @@ async def create_marker_definition( direction=def_data.direction, color=def_data.color, highlight_duration=def_data.highlight_duration, + data_link_type=def_data.data_link_type, ) return project.marker_definitions.get(name, {}) @@ -297,6 +298,7 @@ async def update_marker_definition( direction=def_data.direction if "direction" in def_data.model_fields_set else _UNSET, color=def_data.color, highlight_duration=def_data.highlight_duration, + data_link_type=def_data.data_link_type if "data_link_type" in def_data.model_fields_set else _UNSET, ) return project.marker_definitions.get(def_name, {}) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 2369fe658..021f66b99 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -1052,6 +1052,7 @@ async def marker_definition( tag: Annotated[int | None, Field(description="Numeric tag for packet correlation")] = None, color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None, highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None, + data_link_type: Annotated[str | None, Field(description="pcap link-layer type for serial links (DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / DLT_ATM_RFC1483). Omit = Ethernet-only (serial links skipped); setting it also covers serial links with that encapsulation")] = None, ) -> list[dict[str, Any]]: """Manage project-level marker definitions — traffic-insight rules that apply to ALL links. @@ -1060,7 +1061,7 @@ async def marker_definition( On delete, 'global-{name}' is removed from every link. Create requires: project_id, action='create', bpf - Update requires: project_id, action='update', def_name, and at least one of (bpf, tag, color, highlight_duration) + Update requires: project_id, action='update', def_name, and at least one of (bpf, tag, color, highlight_duration, data_link_type) Delete requires: project_id, action='delete', def_name List requires: project_id, action='list' @@ -1073,7 +1074,7 @@ async def marker_definition( Common BPF examples: 'arp', 'icmp', 'ospf', 'tcp port 22', 'udp port 53' """ params = {"project_id": project_id, "action": action} - for opt in ("bpf", "def_name", "name", "tag", "color", "highlight_duration"): + for opt in ("bpf", "def_name", "name", "tag", "color", "highlight_duration", "data_link_type"): val = locals().get(opt) if val is not None: params[opt] = val diff --git a/gns3server/api/routes/mcp/links.py b/gns3server/api/routes/mcp/links.py index 7e1afc6a2..314f8b8c9 100644 --- a/gns3server/api/routes/mcp/links.py +++ b/gns3server/api/routes/mcp/links.py @@ -457,7 +457,7 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) if not bpf: return {"error": "bpf is required for create action"} body: dict[str, Any] = {"bpf": bpf} - for opt in ("name", "tag", "color", "highlight_duration"): + for opt in ("name", "tag", "color", "highlight_duration", "data_link_type"): if params.get(opt) is not None: body[opt] = params[opt] # No direction: a definition fans out to every link and auto-selects its @@ -473,11 +473,11 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) if action == "update": body = {} - for opt in ("bpf", "tag", "color", "highlight_duration"): + for opt in ("bpf", "tag", "color", "highlight_duration", "data_link_type"): if params.get(opt) is not None: body[opt] = params[opt] if not body: - return {"error": "At least one update field is required (bpf, tag, color, highlight_duration)"} + return {"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"} return conn.http_call("put", url, json_data=body).json() # action == "delete" diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index aaf3da9a1..fc048892a 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1091,7 +1091,25 @@ class BaseNode: ) i += 1 - async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None, direction=None): + @staticmethod + def _marker_linktype(data_link_type): + """ + Normalize a GNS3 pcap data-link type (e.g. ``DLT_C_HDLC``) to the bare + uBridge ``linktype`` token (``C_HDLC``) by stripping the ``DLT_`` prefix. + Returns ``None`` for Ethernet (``DLT_EN10MB`` / unset) so the ``linktype`` + keyword is omitted and uBridge defaults to EN10MB. Values come straight + from ``SerialPort.data_link_types`` (the single source of truth); uBridge + resolves them with ``pcap_datalink_name_to_val``, which is case-sensitive + and expects the canonical uppercase form. + """ + if not data_link_type: + return None + dlt = data_link_type.upper() + if dlt.startswith("DLT_"): + dlt = dlt[4:] + return None if dlt == "EN10MB" else dlt + + async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None, direction=None, data_link_type=None): """ Attach a `mark` packet filter to a uBridge bridge for traffic insight. @@ -1132,6 +1150,9 @@ class BaseNode: cmd += f" link {link_id}" if direction is not None: cmd += f" dir {direction}" + linktype = self._marker_linktype(data_link_type) + if linktype is not None: + cmd += f" linktype {linktype}" cmd += ' pcap "{path}"'.format(path=pcap_path) # Let BPF compile errors propagate — the marker is the user's intent, so a # bad expression must surface instead of being silently dropped. @@ -1232,7 +1253,8 @@ class BaseNode: ) try: await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag, link_id, - direction=spec.get("direction")) + direction=spec.get("direction"), + data_link_type=spec.get("data_link_type")) except UbridgeError as e: # Swallow BPF compile errors (warn + skip) so a single bad # expression can't break link creation / node restart — mirrors diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index 52a8f63d9..5803a9816 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -1311,6 +1311,9 @@ class IOUVM(BaseNode): direction = spec.get("direction") if direction is not None: cmd += f" dir {direction}" + linktype = self._marker_linktype(spec.get("data_link_type")) + if linktype is not None: + cmd += f" linktype {linktype}" cmd += ' pcap "{path}"'.format(path=pcap_path) try: await self._ubridge_send(cmd) diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 607810d23..4467a0b4e 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -122,13 +122,27 @@ class Link: with a per-link private marker of the same name. It carries an ``inherited_from`` back-reference that (a) guards against per-link edits and (b) lets the project sync changes to every copy at once. + + The pcap link-layer follows the link type: Ethernet is always EN10MB. + A serial link needs the definition's WAN encapsulation (HDLC / PPP / + Frame Relay); if none was chosen the serial link is skipped — an EN10MB + pcap on a serial link is undecodable. """ + def_data_link_type = marker_def.get("data_link_type", "DLT_EN10MB") + if self._link_type == "serial": + if def_data_link_type.upper() == "DLT_EN10MB": + return # definition is Ethernet-only; skip this serial link + data_link_type = def_data_link_type + else: + data_link_type = "DLT_EN10MB" + await self.start_marker( name=f"global-{def_name}", bpf=marker_def["bpf"], tag=marker_def.get("tag"), direction=marker_def.get("direction"), + data_link_type=data_link_type, color=marker_def.get("color"), highlight_duration=marker_def.get("highlight_duration"), enabled=not marker_def.get("paused", False), diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 831fa1952..785ca2eae 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -1000,7 +1000,7 @@ class Project: "For a capture-node-relative direction on a single link, use a per-link marker." ) - async def create_marker_definition(self, name, bpf, tag=None, direction=None, color=None, highlight_duration=None): + async def create_marker_definition(self, name, bpf, tag=None, direction=None, color=None, highlight_duration=None, data_link_type="DLT_EN10MB"): """ Create a project-level marker definition and fan out to every existing link that has a capable node. Links without a capable node are silently @@ -1014,12 +1014,12 @@ class Project: self._validate_marker_definition_bpf(name, bpf) self._validate_marker_definition_direction(name, direction) - self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "direction": direction, "color": color, "highlight_duration": highlight_duration, "paused": False} + self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "direction": direction, "color": color, "highlight_duration": highlight_duration, "data_link_type": data_link_type, "paused": False} await self._apply_def_to_all_links(name) self.dump() self.emit_notification("project.updated", self.asdict()) - async def update_marker_definition(self, name, bpf=None, tag=None, direction=_UNSET, color=None, highlight_duration=None): + async def update_marker_definition(self, name, bpf=None, tag=None, direction=_UNSET, color=None, highlight_duration=None, data_link_type=_UNSET): """ Update a marker definition and sync every inherited copy on every link. """ @@ -1042,15 +1042,33 @@ class Project: if direction is not _UNSET: self._validate_marker_definition_direction(name, direction) d["direction"] = direction # None = clear back to both directions + if data_link_type is not _UNSET: + d["data_link_type"] = data_link_type - # Sync: update every inherited copy across all links. - for link in list(self._links.values()): - marker_name = f"global-{name}" - if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name: - await link.update_marker( - marker_name, bpf=d["bpf"], tag=d.get("tag"), direction=d.get("direction"), color=d.get("color"), - highlight_duration=d.get("highlight_duration"), inherited=True - ) + if data_link_type is not _UNSET: + # data_link_type decides which links host an inherited copy (serial + # links are skipped unless a WAN encapsulation is chosen), so a change + # needs a full re-fan-out: drop every copy, then re-apply. + for link in list(self._links.values()): + marker_name = f"global-{name}" + if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name: + try: + await link.stop_marker(marker_name, inherited=True) + except ControllerError: + log.warning( + "Failed to remove inherited marker %s from link %s", + marker_name, link.id + ) + await self._apply_def_to_all_links(name) + else: + # Sync: update every inherited copy across all links. + for link in list(self._links.values()): + marker_name = f"global-{name}" + if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name: + await link.update_marker( + marker_name, bpf=d["bpf"], tag=d.get("tag"), direction=d.get("direction"), color=d.get("color"), + highlight_duration=d.get("highlight_duration"), inherited=True + ) self.dump() self.emit_notification("project.updated", self.asdict()) diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 68b06b27f..d9ca8f250 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -57,15 +57,18 @@ class UDPLink(Link): def _markers_for_node(self, node): """ - Marker specs (name -> {bpf, tag, link_id, direction, enabled}) for the - markers whose capture side is ``node``. Routed by capture_node_id so a - marker only rides the NIO of the node whose uBridge will host it. A - disabled marker is included (installed then turned ``off`` at uBridge, - not dropped) so the UI can toggle it instantly without an NIO rebuild. + Marker specs (name -> {bpf, tag, link_id, direction, data_link_type, + enabled}) for the markers whose capture side is ``node``. Routed by + capture_node_id so a marker only rides the NIO of the node whose uBridge + will host it. A disabled marker is included (installed then turned + ``off`` at uBridge, not dropped) so the UI can toggle it instantly + without an NIO rebuild. """ return { name: {"bpf": m["bpf"], "tag": m.get("tag"), "link_id": self._id, - "direction": m.get("direction"), "enabled": m.get("enabled", True)} + "direction": m.get("direction"), + "data_link_type": m.get("data_link_type", "DLT_EN10MB"), + "enabled": m.get("enabled", True)} for name, m in self._markers.items() if m.get("capture_node_id") == node.id } @@ -350,7 +353,7 @@ class UDPLink(Link): # explicitly deletes a marker via the REST API, and a marker is torn # down automatically only when its link is deleted. - async def start_marker(self, name, bpf, tag=None, direction=None, capture_node_id=None, color=None, highlight_duration=None, enabled=True, inherited_from=None): + async def start_marker(self, name, bpf, tag=None, direction=None, data_link_type="DLT_EN10MB", capture_node_id=None, color=None, highlight_duration=None, enabled=True, inherited_from=None): """ Attach a traffic-insight marker to this link. @@ -401,6 +404,7 @@ class UDPLink(Link): "highlight_duration": highlight_duration, "capture_node_id": marker_side["node"].id, "direction": direction, + "data_link_type": data_link_type, } if inherited_from: marker_entry["inherited_from"] = inherited_from diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 853293f8c..fef63f8b1 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -189,6 +189,19 @@ class MarkerCreate(BaseModel): "Omitted = server auto-picks (first started marker-capable endpoint)." ), ) + data_link_type: str = Field( + "DLT_EN10MB", + description=( + "pcap link-layer type the marker's BPF compiles against and its " + "capture file is written with (a uBridge `linktype` token). Defaults " + "to DLT_EN10MB (Ethernet), which is omitted from the uBridge command. " + "Only meaningful for serial links: set it to the matching serial DLT " + "from the port's data_link_types — DLT_C_HDLC / DLT_PPP_SERIAL / " + "DLT_FRELAY / DLT_ATM_RFC1483 — so the BPF offsets and pcap decode " + "match the encapsulation configured in IOS. Create-only (changing it " + "would invalidate the pcap)." + ), + ) @field_validator("direction", mode="before") @classmethod @@ -259,6 +272,17 @@ class MarkerDefinitionCreate(BaseModel): pattern=r"^(tx|rx|both)$", description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only, 'both' or null = both directions.", ) + data_link_type: str = Field( + "DLT_EN10MB", + description=( + "pcap link-layer type for inherited markers on serial links (uBridge " + "`linktype`). Defaults to DLT_EN10MB (Ethernet): the definition then " + "applies only to Ethernet links and serial links are skipped. Set a " + "serial DLT — DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / " + "DLT_ATM_RFC1483 — to also cover serial links with that encapsulation; " + "Ethernet links stay EN10MB regardless. Changing it re-fans-out." + ), + ) @field_validator("direction", mode="before") @classmethod diff --git a/tests/compute/test_base_node.py b/tests/compute/test_base_node.py index 3eaadb9f9..38371538d 100644 --- a/tests/compute/test_base_node.py +++ b/tests/compute/test_base_node.py @@ -230,6 +230,52 @@ async def test_apply_markers_turns_disabled_filter_off(compute_project, manager) assert node._marker_filter_bridges["m", "L1"] == "VPCS-10" +def test_marker_linktype_normalizes(): + # Ethernet / unset → None (linktype omitted; uBridge defaults to EN10MB). + assert VPCSVM._marker_linktype(None) is None + assert VPCSVM._marker_linktype("") is None + assert VPCSVM._marker_linktype("DLT_EN10MB") is None + # Serial DLTs from SerialPort.data_link_types, DLT_ prefix stripped. + assert VPCSVM._marker_linktype("DLT_C_HDLC") == "C_HDLC" + assert VPCSVM._marker_linktype("DLT_PPP_SERIAL") == "PPP_SERIAL" + assert VPCSVM._marker_linktype("DLT_FRELAY") == "FRELAY" + assert VPCSVM._marker_linktype("DLT_ATM_RFC1483") == "ATM_RFC1483" + # Case-insensitive input → canonical uppercase (pcap_datalink_name_to_val is + # case-sensitive and expects the uppercase form). Shared by base_node and IOU. + assert VPCSVM._marker_linktype("dlt_c_hdlc") == "C_HDLC" + + +@pytest.mark.asyncio +async def test_apply_markers_appends_linktype_for_serial(compute_project, manager): + # A serial data_link_type reaches the uBridge mark command as `linktype C_HDLC` + # so the BPF offsets and pcap decode match the WAN encapsulation. + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + node._ubridge_send = AsyncioMagicMock() + nio = NIOUDP(1234, "127.0.0.1", 4321) + nio.markers = {"m": {"bpf": "icmp", "tag": None, "link_id": "L1", + "direction": None, "data_link_type": "DLT_C_HDLC", "enabled": True}} + with patch("gns3server.compute.marker.marker_manager.MarkerManager") as mm: + mm.instance.return_value.register = MagicMock() + await node._ubridge_apply_markers("VPCS-10", nio) + sent = [c.args[0] for c in node._ubridge_send.call_args_list] + assert any("linktype C_HDLC" in s for s in sent) + + +@pytest.mark.asyncio +async def test_apply_markers_omits_linktype_for_ethernet(compute_project, manager): + # Ethernet (DLT_EN10MB) → no linktype keyword; uBridge defaults to EN10MB. + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + node._ubridge_send = AsyncioMagicMock() + nio = NIOUDP(1234, "127.0.0.1", 4321) + nio.markers = {"m": {"bpf": "icmp", "tag": None, "link_id": "L1", + "direction": None, "data_link_type": "DLT_EN10MB", "enabled": True}} + with patch("gns3server.compute.marker.marker_manager.MarkerManager") as mm: + mm.instance.return_value.register = MagicMock() + await node._ubridge_apply_markers("VPCS-10", nio) + sent = [c.args[0] for c in node._ubridge_send.call_args_list] + assert not any("linktype" in s for s in sent) + + @pytest.mark.asyncio async def test_delete_marker_capture_removes_pcap_and_entry(compute_project, manager): # Deleting a marker's capture removes its pcap and forgets the bridge entry. diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py index d57157f2d..1c547f1ad 100644 --- a/tests/controller/test_marker.py +++ b/tests/controller/test_marker.py @@ -34,6 +34,7 @@ from tests.utils import AsyncioMagicMock from gns3server.controller.udp_link import UDPLink from gns3server.controller.ports.ethernet_port import EthernetPort +from gns3server.controller.ports.serial_port import SerialPort from gns3server.controller.node import Node from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError @@ -55,17 +56,21 @@ def _valid_bpf(): return stack -async def _make_link(project): - """Build a created UDPLink between two VPCS nodes on a mocked compute.""" +async def _make_link(project, port_cls=EthernetPort): + """Build a created UDPLink between two VPCS nodes on a mocked compute. + + ``port_cls`` defaults to EthernetPort; pass SerialPort for a serial link + (the link's link_type follows the port). + """ compute = MagicMock() compute.id = "local" compute.host = "example.com" node1 = Node(project, compute, "n1", node_type="vpcs") - node1._ports = [EthernetPort("E0", 0, 0, 0)] + node1._ports = [port_cls("E0", 0, 0, 0)] node2 = Node(project, compute, "n2", node_type="vpcs") - node2._ports = [EthernetPort("E0", 0, 0, 1)] + node2._ports = [port_cls("E0", 0, 0, 1)] async def subnet(_other): return ("192.168.1.1", "192.168.1.2") @@ -110,6 +115,29 @@ async def test_start_marker_stores_entry(project): assert "inherited_from" not in entry +@pytest.mark.asyncio +async def test_start_marker_stores_data_link_type(project): + # data_link_type is stored on the marker and flows into the per-node spec + # (the compute-side source for the uBridge `linktype` keyword). Serial-only; + # defaults to DLT_EN10MB when omitted (Ethernet → linktype omitted). + with _valid_bpf(): + link = await _make_link(project) + await link.start_marker("ospf", "ospf", data_link_type="DLT_C_HDLC") + + assert link.markers["ospf"]["data_link_type"] == "DLT_C_HDLC" + capture_id = link.markers["ospf"]["capture_node_id"] + capture_side = next(n for n in link._nodes if n["node"].id == capture_id) + assert link._markers_for_node(capture_side["node"])["ospf"]["data_link_type"] == "DLT_C_HDLC" + + # Default when omitted = Ethernet. + with _valid_bpf(): + link2 = await _make_link(project) + await link2.start_marker("icmp", "icmp") + cid = link2.markers["icmp"]["capture_node_id"] + cside = next(n for n in link2._nodes if n["node"].id == cid) + assert link2._markers_for_node(cside["node"])["icmp"]["data_link_type"] == "DLT_EN10MB" + + @pytest.mark.asyncio async def test_start_marker_pins_capture_node(project): # Auto-pick would choose node1 (first endpoint); pin to node2 explicitly. @@ -420,6 +448,46 @@ async def test_update_marker_definition_syncs(project): assert project.marker_definitions["arp"]["highlight_duration"] == 1500 +@pytest.mark.asyncio +async def test_definition_serial_dlt_fans_out_to_serial_link(project): + # A definition with a serial data_link_type covers serial links with that + # encapsulation AND ethernet links with EN10MB (one definition, mixed topo). + with _valid_bpf(): + serial_link = await _make_link(project, SerialPort) + eth_link = await _make_link(project) + await project.create_marker_definition("ospf", "ospf", data_link_type="DLT_C_HDLC") + + assert serial_link._link_type == "serial" + assert serial_link.markers["global-ospf"]["data_link_type"] == "DLT_C_HDLC" + assert eth_link.markers["global-ospf"]["data_link_type"] == "DLT_EN10MB" + + +@pytest.mark.asyncio +async def test_definition_default_skips_serial_link(project): + # Default (EN10MB) definition is Ethernet-only: serial links are skipped + # (an EN10MB pcap on a serial link would be undecodable). + with _valid_bpf(): + serial_link = await _make_link(project, SerialPort) + eth_link = await _make_link(project) + await project.create_marker_definition("arp", "arp") + + assert "global-arp" not in serial_link.markers + assert "global-arp" in eth_link.markers + + +@pytest.mark.asyncio +async def test_update_definition_data_link_type_refans_out(project): + # Changing data_link_type re-evaluates which links host the marker: a serial + # link skipped under the default gains the marker once a WAN DLT is chosen. + with _valid_bpf(): + serial_link = await _make_link(project, SerialPort) + await project.create_marker_definition("ospf", "ospf") + assert "global-ospf" not in serial_link.markers # default → serial skipped + await project.update_marker_definition("ospf", data_link_type="DLT_PPP_SERIAL") + + assert serial_link.markers["global-ospf"]["data_link_type"] == "DLT_PPP_SERIAL" + + @pytest.mark.asyncio async def test_delete_marker_definition_clears(project): """Regression: deleting a def must remove inherited copies from every link.""" From ba318150facdc2ed55997cce1200f5dc65c4c1b5 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 7 Aug 2026 13:30:12 +0800 Subject: [PATCH 32/36] iou: skip already-installed markers in _ubridge_apply_markers (NIO update idempotency) The IOU override of _ubridge_apply_markers lacked the incremental guard the generic path has, so on an NIO update it re-added every marker the port already carried. uBridge's add_packet_filter rejects a duplicate filter name (packet_filter.c find_packet_filter), so adding a private marker to an IOU link that already hosted an inherited global-* copy failed with 'Failed to add filter global-...' -- the NIO update re-sends ALL markers on the port (inherited + new private), and the pre-existing one collided. Add the same (name, link_id) in self._marker_filter_bridges skip as the generic base_node path, so an update only installs markers not already on the port. Mirrors how Dynamips/vpcs/etc. stay idempotent across add + update. --- gns3server/compute/iou/iou_vm.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index 5803a9816..edba56876 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -1290,9 +1290,18 @@ class IOUVM(BaseNode): bridge_name=bridge_name, bay=adapter_number, unit=port_number ) for name, spec in markers.items(): + link_id = spec.get("link_id", "") + # Incremental: skip markers already installed on this port. A NIO + # update carries EVERY marker on the port (e.g. an inherited + # global-* copy plus a newly added private one); uBridge's + # add_packet_filter rejects a duplicate filter name (packet_filter.c), + # so we must not re-add one already here — mirrors the generic + # _ubridge_apply_markers guard. A fresh bridge has an empty map + # (cleared on _stop_ubridge) so all are installed. + if (name, link_id) in self._marker_filter_bridges: + continue bpf = spec.get("bpf", "") tag = spec.get("tag") - link_id = spec.get("link_id", "") pcap_path = os.path.join( markers_dir, f"{self._id}_{link_id}_{name}.pcap" ) From 0f86dabfa3899a36f79341b28844e9e04c643494 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 7 Aug 2026 13:38:33 +0800 Subject: [PATCH 33/36] marker: forward data_link_type on per-link marker create The per-link create_marker route (links.py) called start_marker without the data_link_type from the body, so it always defaulted to DLT_EN10MB and a serial encapsulation chosen by the caller was silently dropped. Same oversight the IOU capture and definition routes had; one-arg fix. --- gns3server/api/routes/controller/links.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index 522f692cc..bf6c5467f 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -469,6 +469,7 @@ async def create_marker( capture_node_id=marker_data.capture_node_id, color=marker_data.color, highlight_duration=marker_data.highlight_duration, + data_link_type=marker_data.data_link_type, ) return link.markers.get(name, {}) From 078cf92aef5b960e1067407ea8c0d0726e22194e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 7 Aug 2026 23:50:41 +0800 Subject: [PATCH 34/36] marker: concurrent (bounded) definition fan-out The per-definition fan-out applied markers to links in a serial loop -- one compute round-trip per link. On a 1000-link project that serializes N HTTP round-trips (minutes on remote computes). Fan out with asyncio.gather + Semaphore(32): links are independent (own _markers/_link_data), per-link ControllerError stays isolated, and Project.dump is synchronous + atomic (tmp + rename) so concurrent dumps cannot corrupt the topology file. Converts the definition-create fan-out, the definition-update sync and re-fan-out loops, and the definition-delete cleanup to the shared _marker_apply_concurrently helper. apply_defs_to_new_link stays serial deliberately: all definitions share one link and each push carries the link's full marker set, so concurrent pushes would race and lose markers. --- gns3server/controller/project.py | 106 ++++++++++++++++++++----------- tests/controller/test_marker.py | 15 +++++ 2 files changed, 83 insertions(+), 38 deletions(-) diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 785ca2eae..177a2cc9a 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -1045,30 +1045,33 @@ class Project: if data_link_type is not _UNSET: d["data_link_type"] = data_link_type + # Links that currently carry an inherited copy of this definition. + affected = [ + link for link in self._links.values() + if f"global-{name}" in link.markers + and link.markers[f"global-{name}"].get("inherited_from") == name + ] + if data_link_type is not _UNSET: # data_link_type decides which links host an inherited copy (serial # links are skipped unless a WAN encapsulation is chosen), so a change # needs a full re-fan-out: drop every copy, then re-apply. - for link in list(self._links.values()): - marker_name = f"global-{name}" - if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name: - try: - await link.stop_marker(marker_name, inherited=True) - except ControllerError: - log.warning( - "Failed to remove inherited marker %s from link %s", - marker_name, link.id - ) + await self._marker_apply_concurrently( + affected, + lambda link: link.stop_marker(f"global-{name}", inherited=True), + lambda link, e: f"Failed to remove inherited marker global-{name} from link {link.id}: {e}", + ) await self._apply_def_to_all_links(name) else: # Sync: update every inherited copy across all links. - for link in list(self._links.values()): - marker_name = f"global-{name}" - if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name: - await link.update_marker( - marker_name, bpf=d["bpf"], tag=d.get("tag"), direction=d.get("direction"), color=d.get("color"), - highlight_duration=d.get("highlight_duration"), inherited=True - ) + await self._marker_apply_concurrently( + affected, + lambda link: link.update_marker( + f"global-{name}", bpf=d["bpf"], tag=d.get("tag"), direction=d.get("direction"), + color=d.get("color"), highlight_duration=d.get("highlight_duration"), inherited=True + ), + lambda link, e: f"Failed to sync marker global-{name} on link {link.id}: {e}", + ) self.dump() self.emit_notification("project.updated", self.asdict()) @@ -1084,17 +1087,17 @@ class Project: del self._marker_definitions[name] - for link in list(self._links.values()): - marker_name = f"global-{name}" - if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name: - try: - await link.stop_marker(marker_name, inherited=True) - except ControllerError: - # A missing compute or broken link shouldn't block the delete. - log.warning( - "Failed to remove inherited marker %s from link %s", - marker_name, link.id - ) + affected = [ + link for link in self._links.values() + if f"global-{name}" in link.markers + and link.markers[f"global-{name}"].get("inherited_from") == name + ] + await self._marker_apply_concurrently( + affected, + lambda link: link.stop_marker(f"global-{name}", inherited=True), + # A missing compute or broken link shouldn't block the delete. + lambda link, e: f"Failed to remove inherited marker global-{name} from link {link.id}: {e}", + ) self.dump() self.emit_notification("project.updated", self.asdict()) @@ -1107,21 +1110,21 @@ class Project: """ d = self._marker_definitions[def_name] - for link in list(self._links.values()): - try: - await link.inherit_marker(def_name, d) - except ControllerError as e: - # Per-link failures (e.g. no capable node) shouldn't block the - # definition from serving the rest. - log.warning( - "Marker definition '%s' could not be applied to link %s: %s", - def_name, link.id, e - ) + await self._marker_apply_concurrently( + list(self._links.values()), + lambda link: link.inherit_marker(def_name, d), + lambda link, e: f"Marker definition '{def_name}' could not be applied to link {link.id}: {e}", + ) async def apply_defs_to_new_link(self, link): """ Apply every active marker definition to a newly created link so it inherits project-level rules automatically. + + Deliberately serial: all definitions share the same link, and each + ``inherit_marker`` pushes the link's full marker set — concurrent + pushes would race (a later push overwriting an earlier one's spec and + losing markers). """ for def_name, d in self._marker_definitions.items(): @@ -1133,6 +1136,33 @@ class Project: def_name, link.id, e ) + async def _marker_apply_concurrently(self, links, operation, fail_msg): + """ + Run an async per-link marker operation across *links* with bounded + concurrency. A serial loop takes N sequential compute round-trips — a + definition over 1000 links would take minutes on remote computes — so + fan out in parallel batches. Links are independent (own ``_markers`` / + ``_link_data``), so this is race-free; per-link ``ControllerError`` is + logged and skipped, preserving the serial loop's isolation semantics. + ``Project.dump`` is synchronous and writes atomically (tmp + rename), + so concurrent dumps from the fan-out cannot corrupt the topology file. + + :param links: iterable of links to operate on + :param operation: async callable ``(link) -> coroutine`` + :param fail_msg: callable ``(link, error) -> log message`` + """ + + sem = asyncio.Semaphore(32) + + async def guarded(link): + async with sem: + try: + await operation(link) + except ControllerError as e: + log.warning(fail_msg(link, e)) + + await asyncio.gather(*(guarded(link) for link in links)) + @property def snapshots(self): """ diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py index 1c547f1ad..c5297aeff 100644 --- a/tests/controller/test_marker.py +++ b/tests/controller/test_marker.py @@ -433,6 +433,21 @@ async def test_create_marker_definition_fans_out(project): assert project.marker_definitions["arp"]["highlight_duration"] == 1200 +@pytest.mark.asyncio +async def test_definition_fans_out_over_many_links(project): + # The fan-out is concurrent (bounded) — a large topology must not serialize + # N compute round-trips — but behaviorally every link still receives the + # marker and per-link failures stay isolated. + with _valid_bpf(): + links = [await _make_link(project) for _ in range(20)] + await project.create_marker_definition("arp", "arp", highlight_duration=700) + + for link in links: + assert link.markers["global-arp"]["highlight_duration"] == 700 + assert link.markers["global-arp"]["inherited_from"] == "arp" + assert project.marker_definitions["arp"]["highlight_duration"] == 700 + + @pytest.mark.asyncio async def test_update_marker_definition_syncs(project): From ad8bac8328fb64e7ff63a5a319b98fdb7f8dd213 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 8 Aug 2026 00:03:30 +0800 Subject: [PATCH 35/36] marker: batch topology dumps in bulk fan-out (per-def on 500+ links was ~1 minute) Every per-link marker operation (start_marker / stop_marker / update_marker) called Project.dump() -- a full topology serialization + file write. A definition fan-out over 500 links therefore wrote the whole topology 500+ times (each blocking the event loop), which dominated the observed ~1 minute; the NIO round-trips themselves were negligible. Add a dump: bool = True parameter to the three per-link operations and inherit_marker (matching the existing dump param on Link.add_node). The bulk paths -- definition create fan-out, definition-update sync and re-fan-out, definition-delete cleanup, pause/resume, new-link inheritance -- pass dump=False and their caller dumps once after. apply_defs_to_new_link suppresses per-def dumps too: link create / project open dump once after, so opening a 500-link project with N definitions no longer does 500xN topology writes. --- gns3server/controller/link.py | 3 ++- gns3server/controller/project.py | 38 +++++++++++++++++++++++-------- gns3server/controller/udp_link.py | 17 +++++++++----- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 4467a0b4e..e716bfd03 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -114,7 +114,7 @@ class Link: """ return self._markers - async def inherit_marker(self, def_name, marker_def): + async def inherit_marker(self, def_name, marker_def, dump=True): """ Apply a project-level marker definition to this link. @@ -147,6 +147,7 @@ class Link: highlight_duration=marker_def.get("highlight_duration"), enabled=not marker_def.get("paused", False), inherited_from=def_name, + dump=dump, ) def _persist_markers(self): diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 177a2cc9a..e6a0ffd6a 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -939,9 +939,15 @@ class Project: raise ControllerError(f"Marker definition '{name}' not found") self._marker_definitions[name]["paused"] = True marker_name = f"global-{name}" - for link in list(self._links.values()): - if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name: - await link.update_marker(marker_name, enabled=False, inherited=True) + affected = [ + link for link in self._links.values() + if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name + ] + await self._marker_apply_concurrently( + affected, + lambda link: link.update_marker(marker_name, enabled=False, inherited=True, dump=False), + lambda link, e: f"Failed to pause marker {marker_name} on link {link.id}: {e}", + ) self.dump() self.emit_notification("project.updated", self.asdict()) @@ -952,9 +958,15 @@ class Project: raise ControllerError(f"Marker definition '{name}' not found") self._marker_definitions[name]["paused"] = False marker_name = f"global-{name}" - for link in list(self._links.values()): - if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name: - await link.update_marker(marker_name, enabled=True, inherited=True) + affected = [ + link for link in self._links.values() + if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name + ] + await self._marker_apply_concurrently( + affected, + lambda link: link.update_marker(marker_name, enabled=True, inherited=True, dump=False), + lambda link, e: f"Failed to resume marker {marker_name} on link {link.id}: {e}", + ) self.dump() self.emit_notification("project.updated", self.asdict()) @@ -1058,7 +1070,7 @@ class Project: # needs a full re-fan-out: drop every copy, then re-apply. await self._marker_apply_concurrently( affected, - lambda link: link.stop_marker(f"global-{name}", inherited=True), + lambda link: link.stop_marker(f"global-{name}", inherited=True, dump=False), lambda link, e: f"Failed to remove inherited marker global-{name} from link {link.id}: {e}", ) await self._apply_def_to_all_links(name) @@ -1068,7 +1080,8 @@ class Project: affected, lambda link: link.update_marker( f"global-{name}", bpf=d["bpf"], tag=d.get("tag"), direction=d.get("direction"), - color=d.get("color"), highlight_duration=d.get("highlight_duration"), inherited=True + color=d.get("color"), highlight_duration=d.get("highlight_duration"), inherited=True, + dump=False ), lambda link, e: f"Failed to sync marker global-{name} on link {link.id}: {e}", ) @@ -1110,9 +1123,12 @@ class Project: """ d = self._marker_definitions[def_name] + # dump=False: per-link topology writes are the dominant cost on large + # projects — the callers (create/update_marker_definition) dump once + # after the fan-out. await self._marker_apply_concurrently( list(self._links.values()), - lambda link: link.inherit_marker(def_name, d), + lambda link: link.inherit_marker(def_name, d, dump=False), lambda link, e: f"Marker definition '{def_name}' could not be applied to link {link.id}: {e}", ) @@ -1129,7 +1145,9 @@ class Project: for def_name, d in self._marker_definitions.items(): try: - await link.inherit_marker(def_name, d) + # dump=False: the caller (link create / project open) dumps once + # after; per-def dumps here would be N full topology writes. + await link.inherit_marker(def_name, d, dump=False) except ControllerError as e: log.warning( "Marker definition '%s' could not be applied to new link %s: %s", diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index d9ca8f250..b25e1126b 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -353,7 +353,7 @@ class UDPLink(Link): # explicitly deletes a marker via the REST API, and a marker is torn # down automatically only when its link is deleted. - async def start_marker(self, name, bpf, tag=None, direction=None, data_link_type="DLT_EN10MB", capture_node_id=None, color=None, highlight_duration=None, enabled=True, inherited_from=None): + async def start_marker(self, name, bpf, tag=None, direction=None, data_link_type="DLT_EN10MB", capture_node_id=None, color=None, highlight_duration=None, enabled=True, inherited_from=None, dump=True): """ Attach a traffic-insight marker to this link. @@ -412,9 +412,12 @@ class UDPLink(Link): if self._created: await self.update() self._project.emit_notification("link.updated", self.asdict()) - self._project.dump() + # Bulk fan-out passes dump=False: N per-link topology writes on a + # 500-link project are the dominant cost — the caller dumps once after. + if dump: + self._project.dump() - async def stop_marker(self, name, inherited=False): + async def stop_marker(self, name, inherited=False, dump=True): """ Remove a traffic-insight marker from this link. @@ -455,9 +458,10 @@ class UDPLink(Link): except Exception: pass # best-effort: old compute without the route leaves the file self._project.emit_notification("link.updated", self.asdict()) - self._project.dump() + if dump: + self._project.dump() - async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=_UNSET, color=None, highlight_duration=None, inherited=False): + async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=_UNSET, color=None, highlight_duration=None, inherited=False, dump=True): """ Update an existing marker's fields and push to uBridge fine-grained — no full NIO reapply, so sibling markers' pcaps stay open. bpf/tag/direction @@ -536,4 +540,5 @@ class UDPLink(Link): # correct in _markers; the next NIO reapply converges uBridge. pass self._project.emit_notification("link.updated", self.asdict()) - self._project.dump() + if dump: + self._project.dump() From 82945cd81e68ac760ce86098ae9065183645da08 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 8 Aug 2026 00:26:00 +0800 Subject: [PATCH 36/36] server: raise the open-files (RLIMIT_NOFILE) limit at startup Every started node holds ~3 file descriptors in the server's table (pidfd + stdout/stderr pipes per child process), so a few hundred started nodes exhaust the default 1024 soft limit and the uBridge version-check subprocess fails with EMFILE ('Too many open files: /dev/null'). At startup, best-effort raise RLIMIT_NOFILE to 65535 (capped by the hard limit); failures are logged, never fatal. Runs before daemonize() so the daemon inherits the raised limit. Linux-only, matching the platform target. --- gns3server/main.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/gns3server/main.py b/gns3server/main.py index bb30a2d81..4069f5e3a 100644 --- a/gns3server/main.py +++ b/gns3server/main.py @@ -31,6 +31,8 @@ import os import sys import asyncio import argparse +import logging +import resource def daemonize(): @@ -97,6 +99,34 @@ def parse_arguments(argv): return parser, args +log = logging.getLogger(__name__) + + +def _raise_open_files_limit(target=65535): + """ + Raise RLIMIT_NOFILE at startup so large topologies don't hit EMFILE. + Every started node holds ~3 file descriptors in the server's table + (pidfd + stdout/stderr pipes per child process), so a few hundred nodes + exhaust the default 1024 limit. Best-effort: the hard limit caps what we + can request; failures are logged but never fatal. Runs before daemonize() + so the daemon inherits the raised limit. + """ + try: + soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + if soft >= target: + return + new_soft = min(target, hard) + resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, hard)) + if new_soft < target: + log.warning( + f"Open-files limit raised to {new_soft} (hard limit), below the requested {target}" + ) + else: + log.info(f"Open-files limit raised from {soft} to {new_soft}") + except (OSError, ValueError) as e: + log.warning(f"Could not raise the open-files limit: {e}") + + def main(): """ Entry point for GNS3 server @@ -104,6 +134,7 @@ def main(): if sys.platform.startswith("win"): raise SystemExit("Windows is not a supported platform to run the GNS3 server") + _raise_open_files_limit() if "--daemon" in sys.argv: daemonize()