feat: VPCS kernel datapath

A VPCS node's single adapter anchors on a persistent TAP the server
creates (tap create + set_owner via uBridge) and the vpcs process
attaches to by name: -e -d <gp{id8}e0p0> -n, the same
server-creates/guest-attaches shape as QEMU's netdev. -n (a vpcs fork
feature) keeps the guest's ip command from ifconfig'ing the anchor on
the host; the vpcs build is probed once per binary identity
(vpcs_tap capability, usage scan) and gates kernel links together
with ubridge_tap — anything else keeps the node on the relay (the
legacy local UDP tunnel on a stock binary, an AF_PACKET endpoint on
the anchor with a capable one).

VPCSVM takes KernelDatapathMixin: _connect_nio dispatches kernel
(enslave into the per-link bridge) / relay-on-anchor / legacy,
captures pick capture start_kernel vs the bridge engine, stop runs
under an execute lock and terminates the process before deleting the
anchor TAPs (a held device refuses deletion), and the duplicate-port
guard backstops the controller's. The NIO routes accept nio_bridge
and write suspend back on update.

_delete_anchor_taps verifies each tap is gone from sysfs and retries
a bounded three times: a holder can release its fd a moment after its
process was reaped (vpcs's daemon parent kills its console child and
exits first), so the unconditional delete raced EBUSY and leaked the
anchor intermittently.

The vpcs side needs IFNAMSIZ (16) instead of its own IFNAMESIZ (12)
for the -d name buffer — anchor names are 14 chars and truncated to a
nonexistent device, which TUNSETIFF then tries to create without
CAP_NET_ADMIN (fixed in the vpcs fork).

e2e (tests/e2e/test_vpcs_kernel_datapath.py): kernel and relay
scenarios green against the real binary — real ICMP both ways, §E.1/§
E.2 on the anchors, delay/suspend/capture, link delete/re-create,
node stop/restart self-heal, zero leftovers; the relay control passes
with both the fork binary and a stock one.
This commit is contained in:
YueGuobin committed 2026-10-12 17:19:57 +08:00
1 parent 4ced753e8d
commit c7ffe919dc
20 files changed
+1204 -94

No files matched your search

@@ -31,6 +31,7 @@ from gns3server.compute.ubridge.tc_probe import (
probe_tap_support,
probe_tc_capabilities,
)
from gns3server.compute.vpcs.vpcs_probe import probe_vpcs_tap
from gns3server.utils.path import get_default_project_directory
from gns3server.utils.tc_capabilities import usable_ebpf_modes
from gns3server.version import __version__
@@ -70,6 +71,10 @@ async def get_capabilities(request: Request) -> dict:
# anchor. None = unknown (probe failed or uBridge too old).
ubridge_bridge_tap = await probe_bridge_tap_support()
# VPCS tap-mode anchoring (-e -d with -n): what lets a VPCS node carry a
# kernel link. None = unknown (probe failed or binary missing).
vpcs_tap = await probe_vpcs_tap()
# record the controller hostname or IP address
if request.client:
request.app.state.controller_host = request.client.host
@@ -85,4 +90,5 @@ async def get_capabilities(request: Request) -> dict:
"ubridge_tap": ubridge_tap,
"ubridge_iol_tap": ubridge_iol_tap,
"ubridge_bridge_tap": ubridge_bridge_tap,
"vpcs_tap": vpcs_tap,
}
+7 -6
View File
@@ -175,16 +175,16 @@ async def reload_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
response_model=Union[schemas.UDPNIO, schemas.BridgeNIO],
dependencies=[Depends(compute_authentication)],
)
async def create_vpcs_node_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO],
node: VPCSVM = Depends(dep_node),
) -> schemas.UDPNIO:
) -> Union[schemas.UDPNIO, schemas.BridgeNIO]:
"""
Add a NIO (Network Input/Output) to the node.
The adapter number on the VPCS node is always 0.
@@ -198,16 +198,16 @@ async def create_vpcs_node_nio(
@router.put(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
response_model=Union[schemas.UDPNIO, schemas.BridgeNIO],
dependencies=[Depends(compute_authentication)],
)
async def update_vpcs_node_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO],
node: VPCSVM = Depends(dep_node),
) -> schemas.UDPNIO:
) -> Union[schemas.UDPNIO, schemas.BridgeNIO]:
"""
Update a NIO (Network Input/Output) on the node.
The adapter number on the VPCS node is always 0.
@@ -217,6 +217,7 @@ async def update_vpcs_node_nio(
nio.filters.clear()
if nio_data.filters:
nio.filters = nio_data.filters
nio.suspend = nio_data.suspend
nio.markers = nio_data.markers or {}
await node.port_update_nio_binding(port_number, nio)
return nio.asdict()
+23
View File
@@ -44,6 +44,7 @@ A node type using this mixin provides:
failures surface as ``DockerError`` / ``QemuError`` rather than ``NodeError``.
"""
import asyncio
import contextlib
import logging
import os
@@ -213,12 +214,34 @@ class KernelDatapathMixin:
a held device answers EBADFD and the best-effort delete below would
leak it. The sweep is suppressed per TAP — an anchor already gone
is not an error — and the registry is cleared either way.
A holder's fd can be released a moment after its process was
reaped (VPCS's daemon parent kills its child then exits — the
server reaps the parent before the child's fds are actually gone),
so the first delete races EBUSY. Every tap that is still on the
host afterwards (same-host sysfs check, zero cost on the happy
path) gets a small grace and up to three retries; a survivor is
warned about, not fatal.
"""
if self.ubridge:
for tap in self._kernel_taps.values():
with contextlib.suppress(UbridgeError):
await self._ubridge_send(f'tap delete "{tap}"')
for attempt in range(3):
if not os.path.exists(f"/sys/class/net/{tap}"):
break
await asyncio.sleep(0.3)
with contextlib.suppress(UbridgeError):
await self._ubridge_send(f'tap delete "{tap}"')
else:
if os.path.exists(f"/sys/class/net/{tap}"):
log.warning(
"Node '%s' [%s]: anchor TAP %s survived its delete — a holder still has it open",
self._name,
self._id,
tap,
)
await self._remove_kernel_bridges()
self._kernel_taps.clear()
+92
View File
@@ -0,0 +1,92 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
Standalone VPCS tap-mode probe.
A VPCS node anchors a kernel-datapath link on a persistent TAP the vpcs
process attaches to by name (``-e -d <anchor>``). That shape needs a vpcs
build that also refuses to auto-configure the host side of the TAP (``-n``):
without the flag every ``ip`` command inside the guest runs
``ifconfig <tap> … up`` on the anchor — the server owns the anchor's
addresses and bridge membership, so a vpcs touching them ranges from noise
(an ifconfig on a name that cannot resolve) to sabotage (reconfiguring an
interface that does exist).
The probe asks the configured binary for its usage (``vpcs -h``) and looks
for the ``-n`` line, caching the answer by binary identity (path, mtime,
size, ctime) exactly like the uBridge probes — the same binary never
answers differently, but replacing it must re-probe. This feeds the compute
``/capabilities`` payload (``vpcs_tap``) so the controller can gate VPCS
kernel links on it. Never raises: any failure (missing binary, timeout)
means unknown, reported as None and cached as such — the safe answer keeps
the node on the relay datapath.
"""
import asyncio
import logging
import os
import shutil
from gns3server.config import Config
from gns3server.utils.asyncio import subprocess_check_output
log = logging.getLogger(__name__)
# binary identity (path, mtime, size, ctime) -> whether -n is available
# (None = probed and unknown)
_cache: dict[tuple[str, int, int, int], bool | None] = {}
_lock = asyncio.Lock()
# The stable tail of the -n usage line (the flag itself is wrapped in ANSI
# escapes, the description is not).
_NO_HOSTCONF_NEEDLE = "do not auto-configure"
async def probe_vpcs_tap(timeout: float = 5.0) -> bool | None:
"""
Whether this host's VPCS binary can anchor a kernel link (tap mode with
``-n``), probed once per binary identity.
:returns: True when the usage advertises ``-n``, False when it does not
(an upstream build), None when the probe failed (unknown binary).
"""
config = Config.instance()
vpcs_path = config.settings.VPCS.vpcs_path
if not os.path.isabs(vpcs_path):
vpcs_path = shutil.which(vpcs_path)
if not vpcs_path or not os.path.isfile(vpcs_path):
return None
try:
stat = os.stat(vpcs_path)
key = (vpcs_path, stat.st_mtime_ns, stat.st_size, stat.st_ctime_ns)
except OSError:
return None
async with _lock:
if key in _cache:
return _cache[key]
try:
output = await asyncio.wait_for(subprocess_check_output(vpcs_path, "-h"), timeout=timeout)
except (OSError, asyncio.TimeoutError, ValueError) as e:
log.debug("VPCS tap-mode probe failed: %s", e)
supported = None
else:
supported = _NO_HOSTCONF_NEEDLE in output
_cache[key] = supported
return supported
+300 -71
View File
@@ -31,16 +31,20 @@ import sys
from gns3server.utils import parse_version
from gns3server.utils.asyncio import monitor_process, subprocess_check_output, wait_for_process_termination
from gns3server.utils.kernel_anchor import kernel_anchor_name
from ..adapters.ethernet_adapter import EthernetAdapter
from ..base_node import BaseNode
from ..kernel_datapath import KernelDatapathMixin
from ..nios.nio_bridge import NIOBridge
from ..nios.nio_udp import NIOUDP
from .vpcs_error import VPCSError
from .vpcs_probe import probe_vpcs_tap
log = logging.getLogger(__name__)
class VPCSVM(BaseNode):
class VPCSVM(KernelDatapathMixin, BaseNode):
module_name = "vpcs"
"""
@@ -62,6 +66,13 @@ class VPCSVM(BaseNode):
self._vpcs_version = None
self._started = False
self._local_udp_tunnel = None
self._execute_lock = asyncio.Lock()
# Kernel-datapath state (the anchor is a persistent TAP uBridge
# creates and the vpcs process attaches to — see the datapath
# section below).
self._tap_datapath = False
self._kernel_taps = {}
self._ubridge_tc_caps = None
# VPCS settings
if startup_script is not None and not self.script_file: # We disallow override at startup
@@ -72,6 +83,15 @@ class VPCSVM(BaseNode):
def ethernet_adapter(self):
return self._ethernet_adapter
@property
def _ethernet_adapters(self):
"""
The adapter list the kernel-datapath mixin walks (the per-link
bridge sweep): VPCS owns exactly one single-port adapter.
"""
return [self._ethernet_adapter]
async def close(self):
"""
Closes this VPCS VM.
@@ -89,10 +109,14 @@ class VPCSVM(BaseNode):
self.manager.port_manager.release_udp_port(self._local_udp_tunnel[1].lport, self._project)
self._local_udp_tunnel = None
await self._stop_ubridge()
# stop() tears the datapath down in the right order (the vpcs
# process holds the anchor TAPs open — a held device refuses
# deletion and would leak); the else branch covers a node that is
# not running but may still hold a uBridge with anchors.
if self.is_running():
self._terminate_process()
await self.stop()
else:
await self._stop_ubridge()
return True
@@ -225,34 +249,51 @@ class VPCSVM(BaseNode):
await self._check_requirements()
if not self.is_running():
nio = self._ethernet_adapter.get_nio(0)
command = self._build_command()
# uBridge owns the anchor TAP and must be up before it is
# created — and before vpcs attaches to it. The whole setup runs
# inside the try so a failure at any step (probe refused, tap
# create, launch) takes the datapath down again: a node that
# reports stopped must not keep a uBridge process and a
# root-namespace TAP alive.
self._ubridge_tc_caps = None
try:
log.info(f"Starting VPCS: {command}")
self._vpcs_stdout_file = os.path.join(self.working_dir, "vpcs.log")
log.info(f"Logging to {self._vpcs_stdout_file}")
flags = 0
if sys.platform.startswith("win32"):
flags = subprocess.CREATE_NEW_PROCESS_GROUP
with open(self._vpcs_stdout_file, "w", encoding="utf-8") as fd:
self.command_line = " ".join(command)
self._process = await asyncio.create_subprocess_exec(
*command, stdout=fd, stderr=subprocess.STDOUT, cwd=self.working_dir, creationflags=flags
)
monitor_process(self._process, self._termination_callback)
await self._start_ubridge()
if nio:
await self.add_ubridge_udp_connection(f"VPCS-{self._id}", self._local_udp_tunnel[1], nio)
await self._prepare_tap_datapath()
await self.start_wrap_console()
command = self._build_command()
try:
log.info(f"Starting VPCS: {command}")
self._vpcs_stdout_file = os.path.join(self.working_dir, "vpcs.log")
log.info(f"Logging to {self._vpcs_stdout_file}")
flags = 0
if sys.platform.startswith("win32"):
flags = subprocess.CREATE_NEW_PROCESS_GROUP
with open(self._vpcs_stdout_file, "w", encoding="utf-8") as fd:
self.command_line = " ".join(command)
self._process = await asyncio.create_subprocess_exec(
*command, stdout=fd, stderr=subprocess.STDOUT, cwd=self.working_dir, creationflags=flags
)
monitor_process(self._process, self._termination_callback)
log.info(f"VPCS instance {self.name} started PID={self._process.pid}")
self._started = True
self.status = "started"
except (OSError, subprocess.SubprocessError) as e:
vpcs_stdout = self.read_vpcs_stdout()
log.error(f"Could not start VPCS {self._vpcs_path()}: {e}\n{vpcs_stdout}")
raise VPCSError(f"Could not start VPCS {self._vpcs_path()}: {e}\n{vpcs_stdout}")
if nio:
# a link that outlived a stop is re-attached from its NIO
await self._connect_nio(0, nio)
await self._set_adapter_carrier(0, not nio.suspend)
await self.start_wrap_console()
log.info(f"VPCS instance {self.name} started PID={self._process.pid}")
self._started = True
self.status = "started"
except (OSError, subprocess.SubprocessError) as e:
vpcs_stdout = self.read_vpcs_stdout()
log.error(f"Could not start VPCS {self._vpcs_path()}: {e}\n{vpcs_stdout}")
raise VPCSError(f"Could not start VPCS {self._vpcs_path()}: {e}\n{vpcs_stdout}")
except Exception:
# uBridge was started (and the anchor TAP created) before the
# launch: don't leave either behind on a failed start.
await self._stop_ubridge()
raise
async def _termination_callback(self, returncode):
"""
@@ -263,11 +304,9 @@ class VPCSVM(BaseNode):
if self._started:
log.info("VPCS process has stopped, return code: %d", returncode)
self._started = False
self.status = "stopped"
self._process = None
await self._stop_ubridge()
await super().stop()
# stop() tears the datapath down under the execute lock (an API
# stop may be running concurrently) and sets the status.
await self.stop()
if returncode != 0:
self.project.emit(
"log.error",
@@ -279,24 +318,156 @@ class VPCSVM(BaseNode):
Stops the VPCS process.
"""
await self._stop_ubridge()
if self.is_running():
self._terminate_process()
if self._process.returncode is None:
try:
await wait_for_process_termination(self._process, timeout=3)
except asyncio.TimeoutError:
if self._process.returncode is None:
try:
self._process.kill()
except OSError as e:
log.error(f"Cannot stop the VPCS process: {e}")
async with self._execute_lock:
# Release the datapath once vpcs is gone: the process holds the
# anchor TAP open (it is the guest side), and uBridge's tap
# delete refuses a held device ("Device or resource busy") —
# deleting it while vpcs still ran leaked the persistent TAP,
# because that best-effort delete is suppressed. The process
# monitor calls stop() on its own when vpcs dies, so an API stop
# and a process-death stop run concurrently in the normal case;
# the lock serializes their TAP sweeps (the second one finds
# uBridge already gone and sweeps nothing).
if self.is_running():
self._terminate_process()
if self._process.returncode is None:
try:
await wait_for_process_termination(self._process, timeout=3)
except asyncio.TimeoutError:
if self._process.returncode is None:
log.warning(f'VPCS VM "{self._name}" with PID={self._process.pid} is still running')
try:
self._process.kill()
except OSError as e:
log.error(f"Cannot stop the VPCS process: {e}")
if self._process.returncode is None:
log.warning(f'VPCS VM "{self._name}" with PID={self._process.pid} is still running')
self._process = None
self._started = False
await super().stop()
self._process = None
self._started = False
await self._stop_ubridge()
await super().stop()
# ------------------------------------------------------------------
# Kernel datapath: the adapter anchor is a persistent TAP
# ------------------------------------------------------------------
def _tap_name(self, adapter_number, port_number=0):
"""
Deterministic anchor TAP name for the adapter port (the shared
utils.kernel_anchor naming contract — the controller names a peer's
anchor with the same function when an Ethernet switch absorbs it).
"""
return kernel_anchor_name("vpcs", self._id, adapter_number, port_number)
def _kernel_host_ifc(self, adapter_number, port_number=0):
"""
The persistent TAP this adapter owns, or None before the node
started (or on the legacy UDP relay datapath).
"""
return self._kernel_taps.get((adapter_number, port_number))
def _kernel_anchors(self):
return set(self._kernel_taps.values())
def _kernel_error(self, message):
return VPCSError(message)
async def _prepare_tap_datapath(self):
"""
Decide this start's datapath: a vpcs build with ``-n`` (no host
auto-configuration) and a uBridge with the persistent-TAP module
anchor the adapter on a persistent TAP the vpcs process attaches to
(``-e -d``). Anything else (upstream vpcs, old uBridge, no
CAP_NET_ADMIN) keeps the legacy UDP relay datapath — kernel links
are refused on it.
"""
self._tap_datapath = False
self._kernel_taps.clear()
if not self.ubridge:
return
vpcs_tap = await probe_vpcs_tap()
if vpcs_tap is not True:
# Without -n every guest `ip` command would ifconfig the anchor
# on the host — the server owns the anchor's addresses and
# bridge membership, so the relay datapath is the only safe
# shape. False (a build definitively without -n) is actionable
# for the user; None (probe failed) stays server-log-only.
log.warning(
"VPCS '%s': vpcs '%s' cannot anchor a kernel link (no -n support)", self._name, self._vpcs_path()
)
if vpcs_tap is False:
self.project.emit(
"log.warning",
{
"message": f"VPCS '{self._name}': the vpcs binary does not support tap mode without host "
"auto-configuration (-n); this node runs on the legacy relay datapath and cannot carry "
"kernel links"
},
)
return
probe_error = await self._probe_persistent_taps(f"gp{self._id.replace('-', '')[:8]}prob")
if probe_error is not None:
log.warning("VPCS '%s': uBridge cannot create persistent TAPs (%s)", self._name, probe_error)
self.project.emit(
"log.warning",
{
"message": f"VPCS '{self._name}': uBridge does not support persistent TAPs ({probe_error}); "
"this node runs on the legacy relay datapath and cannot carry kernel links"
},
)
return
self._tap_datapath = True
# The shared lifecycle sweeps leftovers first, creates the TAP and
# hands it to this user (the unprivileged vpcs process opens it
# itself), leaving it down until a link attaches.
await self._create_anchor_taps([(0, 0)], set_owner=True)
async def _stop_ubridge(self):
"""
Stops uBridge, removing the anchor TAP first: it lives in the root
namespace and outlives the process (the tap module needs the control
channel to delete it), and vpcs holding the other end keeps the
device alive until it exits — the caller must terminate vpcs first,
or the suppressed tap delete leaks the anchor. The next start
spawns a fresh uBridge, so the tc capabilities must be probed again.
"""
await self._delete_anchor_taps()
self._ubridge_tc_caps = None
await super()._stop_ubridge()
async def _connect_nio(self, port_number, nio):
"""
Attach a link's NIO to the adapter's TAP anchor: a kernel link
enslaves the TAP into the per-link kernel bridge, a relay link
attaches it to the uBridge relay bridge as an AF_PACKET endpoint.
The legacy datapath (no anchor) bridges the local UDP tunnel
instead.
"""
bridge_name = f"VPCS-{self._id}"
if not self._tap_datapath:
if isinstance(nio, NIOBridge):
raise VPCSError(
f"VPCS '{self._name}' runs on the legacy relay datapath (no anchor TAP) "
"and cannot carry a kernel link"
)
await self.add_ubridge_udp_connection(bridge_name, self._local_udp_tunnel[1], nio)
return
anchor = self._kernel_host_ifc(0, port_number)
if anchor is None:
raise VPCSError(f"VPCS '{self._name}' has no anchor TAP interface; restart the node to attach a link to it")
if isinstance(nio, NIOBridge):
await self._kernel_attach(anchor, nio)
return
await self._relay_attach(anchor, bridge_name, nio)
async def reload(self):
"""
@@ -386,8 +557,15 @@ class VPCSVM(BaseNode):
if not self._ethernet_adapter.port_exists(port_number):
raise VPCSError(f"Port {port_number} doesn't exist on adapter {self._ethernet_adapter}")
# The compute-side backstop of the controller's duplicate-port guard:
# a port carries at most one NIO, and overwriting the one a link
# still owns orphans that link's teardown (its host state leaks).
if self._ethernet_adapter.get_nio(port_number) is not None:
raise VPCSError(f"Port {port_number} of VPCS '{self._name}' already has a link")
if self.is_running():
await self.add_ubridge_udp_connection(f"VPCS-{self._id}", self._local_udp_tunnel[1], nio)
await self._connect_nio(port_number, nio)
await self._set_adapter_carrier(0, not nio.suspend)
self._ethernet_adapter.add_nio(port_number, nio)
log.info(f'VPCS "{self._name}" [{self.id}]: {nio} added to port {port_number}')
@@ -405,7 +583,19 @@ class VPCSVM(BaseNode):
if not self._ethernet_adapter.port_exists(port_number):
raise VPCSError(f"Port {port_number} doesn't exist on adapter {self._ethernet_adapter}")
if self.is_running():
await self.update_ubridge_udp_connection(f"VPCS-{self._id}", self._local_udp_tunnel[1], nio)
if self._tap_datapath and isinstance(nio, NIOBridge):
# Filters or markers changed on an attached kernel link:
# re-apply on the anchor, no re-enslaving.
anchor = self._kernel_host_ifc(0, port_number)
if anchor is not None:
await self._kernel_update(anchor, nio)
elif self._tap_datapath:
# Relay link on the TAP anchor: re-apply on its bridge.
await self._ubridge_apply_filters(f"VPCS-{self._id}", nio.filters)
await self._ubridge_apply_markers(f"VPCS-{self._id}", nio)
else:
await self.update_ubridge_udp_connection(f"VPCS-{self._id}", self._local_udp_tunnel[1], nio)
await self._set_adapter_carrier(0, not nio.suspend)
async def port_remove_nio_binding(self, port_number):
"""
@@ -419,11 +609,16 @@ class VPCSVM(BaseNode):
if not self._ethernet_adapter.port_exists(port_number):
raise VPCSError(f"Port {port_number} doesn't exist on adapter {self._ethernet_adapter}")
nio = self._ethernet_adapter.get_nio(port_number)
await self.stop_capture(port_number)
if self.is_running():
await self._ubridge_send("bridge delete {name}".format(name=f"VPCS-{self._id}"))
if self._tap_datapath and isinstance(nio, NIOBridge):
await self._remove_kernel_nio(nio, 0, port_number)
elif self._tap_datapath:
await self._relay_detach(f"VPCS-{self._id}")
else:
await self._ubridge_send("bridge delete {name}".format(name=f"VPCS-{self._id}"))
nio = self._ethernet_adapter.get_nio(port_number)
if isinstance(nio, NIOUDP):
self.manager.port_manager.release_udp_port(nio.lport, self._project)
self._ethernet_adapter.remove_nio(port_number)
@@ -461,9 +656,25 @@ class VPCSVM(BaseNode):
nio.start_packet_capture(output_file)
if self.ubridge:
await self._ubridge_send(
'bridge start_capture {name} "{output_file}"'.format(name=f"VPCS-{self._id}", output_file=output_file)
)
anchor = self._kernel_host_ifc(0, port_number)
if isinstance(nio, NIOBridge) and anchor is not None:
# Kernel link: capture on the adapter's TAP anchor (AF_PACKET,
# one capture per uBridge process — see _reserve_kernel_capture;
# a failed start rolls the port's flag back so the port cannot
# later stop the winner's capture).
try:
self._reserve_kernel_capture(anchor)
await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"')
except Exception:
self._release_kernel_capture(anchor)
nio.stop_packet_capture()
raise
else:
await self._ubridge_send(
'bridge start_capture {name} "{output_file}"'.format(
name=f"VPCS-{self._id}", output_file=output_file
)
)
log.info(f"VPCS '{self.name}' [{self.id}]: starting packet capture on port {port_number}")
@@ -480,7 +691,16 @@ class VPCSVM(BaseNode):
nio.stop_packet_capture()
if self.ubridge:
await self._ubridge_send("bridge stop_capture {name}".format(name=f"VPCS-{self._id}"))
anchor = self._kernel_host_ifc(0, port_number)
if isinstance(nio, NIOBridge) and anchor is not None:
# Process-wide and argument-less: only the port owning the
# slot may stop it (a second port's stop would kill this
# capture while its own flag still says capturing).
if self._kernel_capture_owned_by(anchor):
await self._ubridge_send("capture stop_kernel")
self._release_kernel_capture(anchor)
else:
await self._ubridge_send("bridge stop_capture {name}".format(name=f"VPCS-{self._id}"))
log.info(f"VPCS '{self.name}' [{self.id}]: stopping packet capture on port {port_number}")
@@ -511,6 +731,7 @@ class VPCSVM(BaseNode):
tap mode options:
-d vm device name, works only when -i is set to 1
-n do not auto-configure the host tap interface
hypervisor mode option:
-H port run as the hypervisor listening on the tcp 'port'
@@ -532,20 +753,28 @@ class VPCSVM(BaseNode):
else:
log.warning("The VPCS relay feature could not be disabled because the VPCS version is below 0.8b")
# use the local UDP tunnel to uBridge instead
if not self._local_udp_tunnel:
self._local_udp_tunnel = self._create_local_udp_tunnel()
nio = self._local_udp_tunnel[0]
if nio and isinstance(nio, NIOUDP):
# UDP tunnel
command.extend(["-s", str(nio.lport)]) # source UDP port
command.extend(["-c", str(nio.rport)]) # destination UDP port
try:
command.extend(
["-t", socket.gethostbyname(nio.rhost)]
) # destination host, we need to resolve the hostname because VPCS doesn't support it
except socket.gaierror as e:
raise VPCSError(f"Can't resolve hostname {nio.rhost}")
if self._tap_datapath:
# Kernel-datapath anchor: uBridge created the persistent TAP
# before the launch, vpcs attaches to it by name and must leave
# its host-side configuration alone (-n).
command.extend(["-e"]) # tap mode
command.extend(["-d", self._tap_name(0, 0)]) # the anchor TAP
command.extend(["-n"]) # no host auto-configuration on the anchor
else:
# use the local UDP tunnel to uBridge instead
if not self._local_udp_tunnel:
self._local_udp_tunnel = self._create_local_udp_tunnel()
nio = self._local_udp_tunnel[0]
if nio and isinstance(nio, NIOUDP):
# UDP tunnel
command.extend(["-s", str(nio.lport)]) # source UDP port
command.extend(["-c", str(nio.rport)]) # destination UDP port
try:
command.extend(
["-t", socket.gethostbyname(nio.rhost)]
) # destination host, we need to resolve the hostname because VPCS doesn't support it
except socket.gaierror as e:
raise VPCSError(f"Can't resolve hostname {nio.rhost}")
if self.script_file:
command.extend([os.path.basename(self.script_file)])
+13 -1
View File
@@ -221,7 +221,11 @@ class UDPLink(Link):
be present or absent independently of the tap module. Dynamips
Ethernet slot ports anchor on persistent TAPs the hypervisor opens
(the tap module again — the Dynamips binary itself creates nothing),
so it gates like QEMU. A kernel link is an Ethernet segment, so
so it gates like QEMU. A VPCS node's single adapter anchors on a
persistent TAP the vpcs process attaches to by name (``-e -d``),
which needs the tap module plus a vpcs build that keeps its hands
off the host side of the TAP (``-n``) — its own capability on top
of the tap one. A kernel link is an Ethernet segment, so
non-Ethernet ports (IOU serial, Dynamips serial/ATM/POS adapters)
stay on the relay whatever the node's capabilities.
"""
@@ -291,6 +295,14 @@ class UDPLink(Link):
# merely opens (nio create_tap) — the same tap-module capability
# QEMU needs, not a Dynamips-specific one.
return capabilities.get("ubridge_tap") is True
if node.node_type == "vpcs":
# The anchor is a uBridge-created TAP the vpcs process attaches
# to (-e -d): the tap module must exist, and the binary must
# support -n — without it every guest `ip` command would
# ifconfig the anchor on the host. Both strict True: an
# unreported one keeps the link on the relay, where it always
# works.
return capabilities.get("ubridge_tap") is True and capabilities.get("vpcs_tap") is True
return False
async def _prepare(self):
@@ -67,3 +67,9 @@ class Capabilities(BaseModel):
"(bridge delete_nio_tap, the swappable TAP leg an IOL runner container's per-port bridge "
"needs to wire its links on the kernel datapath); absent when the probe failed",
)
vpcs_tap: Optional[bool] = Field(
None,
description="Whether the VPCS binary can anchor a kernel link (tap mode with -n, which "
"keeps the guest from auto-configuring the host side of the anchor TAP); absent when the "
"probe failed",
)
+6 -4
View File
@@ -22,10 +22,11 @@ deterministically: ``g<type>{node_id[:8]}e{adapter}p{port}`` — ``gv`` for a
Docker veth host end, ``gq`` for a QEMU TAP, ``gi`` for an IOU port TAP,
``gd`` for a Dynamips port TAP, ``gx`` for an IOL runner container's port
TAP (a Docker node whose guest leg is unix sockets, so its anchor is a TAP
like IOU's, not a veth). The prefixes keep anchors out of the ``gns3``
bridge/TAP name space and name-collision-free on a host; the 8 hex chars of
the node id plus adapter/port keep every name unique and within IFNAMSIZ
(15).
like IOU's, not a veth), ``gp`` for a VPCS TAP (the vpcs process attaches to
the pre-created anchor with ``-e -d`` instead of speaking UDP). The
prefixes keep anchors out of the ``gns3`` bridge/TAP name space and
name-collision-free on a host; the 8 hex chars of the node id plus
adapter/port keep every name unique and within IFNAMSIZ (15).
The compute classes create their anchors with these helpers (single source
of truth), and so does the controller when a switch link has to name the
@@ -46,6 +47,7 @@ ANCHOR_PREFIX = {
"qemu": "gq",
"iou": "gi",
"dynamips": "gd",
"vpcs": "gp",
}