Merge pull request #2822 from yueguobin/feature/ethernet-switch-ubridge-brctl

POC: migrate builtin EthernetSwitch from Dynamips ethsw to ubridge brctl
This commit is contained in:
Jeremy Grossmann 2026-07-17 23:49:01 +02:00 committed by GitHub
commit e1b09e683e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 956 additions and 254 deletions

View File

@ -24,6 +24,7 @@
### uBridge Permission
- **[uBridge Permission Issue](./gns3-ubridge-permission.md)** - Docker containers fail to start due to missing CAP_NET_ADMIN/CAP_NET_RAW capabilities on uBridge
- **[Docker iptables FORWARD blocks bridge](./docker-iptables-forward-bridge.md)** - Docker sets FORWARD chain to DROP, blocking kernel bridge forwarding; `sudo iptables -P FORWARD ACCEPT` to fix
### Docker Container Stop Delay
- **[Docker Container Stop Delay](./docker-container-stop-delay.md)** - Some containers take ~5s to stop because they don't handle SIGTERM (AlpiNet, OstinatoWireshark)

View File

@ -0,0 +1,38 @@
---
name: docker-iptables-forward-bridge
description: Docker iptables FORWARD DROP blocks kernel bridge forwarding, fix and symptoms
metadata:
type: reference
---
Docker sets the iptables `FORWARD` chain default policy to `DROP` when the
Docker daemon starts. This blocks **all** forwarded traffic through Linux
kernel bridges on the host — including `gns3br{N}` bridges created by the
builtin Ethernet Switch (ubridge `brctl`).
## Symptoms
- Nodes connected to the switch can send frames into the bridge (visible in
`tcpdump -i gns3br{N}`) but never receive forwarded unicast frames.
- `bridge fdb show` may fail to learn MAC addresses (frames dropped before
the bridge learning path).
- ARP and multicast/broadcast may appear to work because they flood, but
unicast replies never reach the destination.
- OSPF Hello / CDP visible on both sides but ICMP echo reply never returns.
- `ubridge bridge get_stats` shows symmetric IN/OUT counts (relay is fine),
`bridge fdb show` shows learned MACs, `bridge link show` shows `state forwarding`
on all ports — yet unicast still doesn't work.
## Fix
Run once per host boot, or make persistent via iptables-persistent / firewall config:
```bash
sudo iptables -P FORWARD ACCEPT
```
## Related
- [[ethernet-switch-ubridge-brctl-migration]] — the kernel bridge that hits this
- [[gns3-server-linux-only]] — datapath constraint
- [[gns3-ubridge-permission]] — another host-level prerequisite (CAP_NET_ADMIN)

View File

@ -0,0 +1,262 @@
<!--
SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information.
-->
> This documentation is AI-generated with reference to actual code and verified
> against real-kernel testing (Linux 7.1.2-1-default). AI can make mistakes —
> please verify against the source code when in doubt.
# Builtin Ethernet Switch — uBridge brctl Backend
## Overview
The historical GNS3 Ethernet Switch was an emulated L2 device inside Dynamips
(`ethsw`). This implementation replaces it with a **real Linux kernel bridge**
driven through uBridge's `brctl` module — one bridge per switch node. The
migration makes the switch a first-class builtin node (no Dynamips dependency)
and enables native-kernel-speed L2 switching with VLAN filtering and QinQ.
| | Old (Dynamips ethsw) | New (uBridge brctl) |
|---|---|---|
| Switching engine | Dynamips user-space emulation | Linux kernel bridge (netlink) |
| VLAN model | ethsw ACL per port | Kernel VLAN filtering + PVID/untagged |
| QinQ | 0x8100/0x88A8/0x9100/0x9200 | 0x8100 (802.1Q) / 0x88A8 (802.1ad) |
| Data path | Node NIO ↔ ethsw NIO (Dynamips) | Node NIO ↔ uBridge relay ↔ TAP ↔ kernel bridge |
| Console | Inactive (reserved TCP port) | None (console_type=none) |
| Node type | `dynamips`-routed | `builtin` (always-on) |
## Architecture
```
┌───────────┐ ┌──────────────┐ ┌───────────────┐ ┌───────────┐
│ Peer A │ │ uBridge │ │ Kernel │ │ Peer B │
│ (Dynamips │◄───►│ per-port │◄───►│ Bridge │◄───►│ (IOU / │
│ / IOU / │ UDP │ relay │ TAP │ gns3{id[:6]}│ TAP │ QEMU / …) │
│ QEMU) │ │ nio_tap↔udp │ │ vlan_filter │ │ │
└───────────┘ └──────────────┘ └──────┬────────┘ └───────────┘
┌─────┴─────┐
│ ... more │
│ ports │
└───────────┘
```
Each switch port is a **dual-role TAP** — uBridge holds the file descriptor as a
`nio_tap` relay endpoint, and the same TAP is enslaved to the kernel bridge via
`brctl addif`. This is the same pattern the Cloud node already uses for host
bridges (`cloud.py:_add_linux_ethernet`). uBridge is **only** the per-port UDP
transport; the kernel bridge performs the actual MAC learning, forwarding, and
VLAN filtering.
### Component map
| File | Role |
|------|------|
| `compute/builtin/nodes/ethernet_switch.py` | Node implementation |
| `api/routes/compute/ethernet_switch_nodes.py` | REST endpoints (repointed to Builtin) |
| `schemas/compute/ethernet_switch_nodes.py` | Request/response models (unchanged) |
| `controller/udp_link.py` | Link creation — pushes NIO to switch via standard adapter endpoint |
## Lifecycle
### `create()``start()`
1. `_start_ubridge(require_privileged_access=True)` — launch uBridge instance
2. `_ensure_bridge()`:
- Derive deterministic bridge name: `gns3` + first 6 hex chars of `self.id`
- `brctl delete` (best-effort — crash recovery, cleans stale interfaces)
- `brctl create`
- `link set … up` (bridge is DOWN after create)
- `brctl vlanfiltering … on`
### `add_nio(nio, port_number)`
Per port, one uBridge relay bridge `{node_id}-{port}` is wired:
```
bridge create {node_id}-{port}
bridge add_nio_tap {node_id}-{port} "{tap}" ← uBridge holds TAP fd
brctl addif "{bridge}" "{tap}" ← enslave to kernel bridge
brctl vlan_del/vlan_add … ← apply port VLAN mode
bridge add_nio_udp {node_id}-{port} lport rhost rport
bridge reset_packet_filters {node_id}-{port} ← from _ubridge_apply_filters
bridge start {node_id}-{port}
```
Captures and marker signals are applied via the existing `_ubridge_apply_filters`
and `_ubridge_apply_markers` helpers from `BaseNode`.
### `remove_nio(port_number)`
```
brctl delif "{bridge}" "{tap}"
bridge delete {node_id}-{port}
release_udp_port(nio.lport)
```
### `close()`
```
for each port: release UDP port
brctl delete "{self._bridge_name}" ← kernel bridge teardown
_stop_ubridge() ← destroys remaining TAPs
```
**Cleanup paths:**
| Scenario | Bridge cleanup | TAP cleanup |
|----------|---------------|-------------|
| Normal project close | `close()``brctl delete` | uBridge stops → TAP fd closed → kernel destroys |
| gns3server crash / kill | Next `_ensure_bridge()``brctl delete` before `create` | uBridge dies → TAP fd closed by kernel |
| Manual project-file deletion after crash | Leaked (no GNS3 record of `gns3{id[:6]}`) | Leaked (same — but uBridge probably dead, TAPs gone with it) |
## Port mode → VLAN translation
All VLAN operations ride on the `brctl` hypervisor module (`../ubridge/doc/brctl.md`).
The kernel bridge must have `vlan_filtering on` before any `vlan_*` call.
### access VLAN N
```
brctl vlan_del {br} {tap} 1 ← remove default PVID 1
brctl vlan_add {br} {tap} N pvid untagged
```
### dot1q trunk (native VLAN V)
A dot1q trunk in ESW is "admit all VLANs tagged, native VLAN PVID + untagged":
```
brctl vlan_del {br} {tap} 1
brctl vlan_add {br} {tap} 1 vid 4094 ← admit all VIDs tagged
brctl vlan_add {br} {tap} V pvid untagged ← override native
```
### qinq (outer VLAN O, ethertype 0x88A8)
Bridge-level (once):
```
brctl setvlanproto {br} 0x88a8 ← switch to 802.1ad (outer S-tag)
```
Port-level:
```
brctl vlan_del {br} {tap} 1
brctl vlan_add {br} {tap} O pvid untagged ← S-tag push for untagged ingress
```
Ethertype 0x8100 qinq ports are treated as plain access ports (the bridge
defaults to 0x8100; no `setvlanproto` needed). Ethertype 0x9100/0x9200 are
not supported by the kernel bridge — see § Limitations.
### Runtime reconfiguration (`update_port_settings`)
On `ports_mapping` update, existing port VLANs are reset before re-apply:
```
brctl delif {br} {tap} ← release from bridge (clears VLAN state)
brctl addif {br} {tap} ← re-enslave (resets to default PVID 1)
brctl vlan_del/vlan_add … ← apply new mode
```
This prevents stale VLAN membership from a previous mode leaking into the new
configuration (e.g., access→trunk transition leaving old access VLAN behind).
## Bridge naming
Deterministic from the switch's UUID: `gns3` + first 6 hex chars (no dashes).
```
gns3a1b2c3 ← bridge (10 chars, ≤ 15 IFNAMSIZ limit)
gns3a1b2c3-0 ← tap for port 0 (12 chars)
gns3a1b2c3-1 ← tap for port 1 (12 chars)
```
- 6 hex = 48 bits of entropy — collision risk is astronomically low even with
thousands of switches on the same host.
- **Crash recovery**: `brctl delete` (best-effort, ignore if not found) then
`brctl create` — stale interfaces from a previous abnormal shutdown are
reclaimed automatically when the switch is re-created.
## Controller integration
No controller or API contract changes are required. The migration is entirely
compute-internal:
- `node_types.BUILTIN_NODE_TYPES` already classified `ethernet_switch` as a
builtin, always-running node.
- `udp_link.create()` pushes the NIO to the switch via the standard
`POST /adapters/0/ports/{p}/nio` endpoint (same as Dynamips).
- The REST API paths, request/response schemas, and port model
(`EthernetSwitchPort`: type/vlan/ethertype) are unchanged.
- `/start`, `/stop`, `/suspend`, `/reload` return 405 (switch is always-on).
The sole observable difference: the `console` field in the response is now
`null` (the switch has no console; `console_type="none"` makes `BaseNode`
skip TCP port reservation). The old Dynamips ethsw returned an unused TCP
port number. Both are valid under `Optional[int]`.
## Known limitations
### Default PVID 1 must be deleted explicitly
A port freshly enslaved to a `vlan_filtering` bridge inherits default PVID 1
(PVID + Egress Untagged). Access/trunk mode application must issue
`vlan_del … 1` first — `vlan_add … pvid` moves the PVID but does not remove
the old PVID's membership. This matches iproute2 semantics and is documented
in `../ubridge/doc/brctl.md#limitations`.
### QinQ is outer-tag (S-VLAN) only
With `setvlanproto 0x88a8` the bridge filters on the outer S-tag; the inner
C-tag passes through transparently. Selective QinQ (inner-VLAN classification
or remapping) requires `IFLA_BRIDGE_VLAN_TUNNEL_INFO` which is not implemented.
Documented in `../ubridge/doc/brctl.md#limitations`.
### Ethertype 0x9100 / 0x9200
The GNS3 schema allows legacy QinQ ethertypes `0x9100` and `0x9200`, but the
Linux kernel bridge only supports `0x8100` (802.1Q) and `0x88a8` (802.1ad).
Configuring these on a qinq port produces a `NodeError` at creation/update
time. Handling policy (map to 0x88A8 + warn vs. reject with error) is
pending per design discussion.
### No FDB read/write
The `brctl` module exposes no `fdb_show`/`fdb_flush`. The kernel bridge
learns and ages MAC entries autonomously; uBridge has never exposed MAC-table
access and gns3-server does not consume it. Consumers that need the FDB
(e.g., a WebUI switch view) should read `/sys/class/net/<br>/brforward` or
`bridge fdb show dev <br>` directly, without uBridge involvement.
## Troubleshooting
### Docker iptables: FORWARD chain DROP
Docker sets the iptables `FORWARD` chain default policy to `DROP` when the
Docker daemon starts. This blocks **all** forwarded traffic through kernel
bridges on the host, including `gns3*` bridges.
**Symptoms**: nodes can send frames into the bridge (visible in `tcpdump -i
gns3*`) but never receive unicast replies. ARP and multicast may work
because they flood, but unicast forwarding silently fails.
**Fix**:
```bash
sudo iptables -P FORWARD ACCEPT
```
### Bridge left DOWN after creation
`brctl create` creates the bridge but leaves it administratively DOWN.
The node now sends `link set … up` after `brctl create`. If forwarding
is not working, verify:
```bash
ip -d link show gns3* | grep -E "state|vlan_filtering"
```
### Kernel version differences
This implementation has been tested on Linux 7.1.2-1-default (x86_64) with
uBridge installed via `make install` (cap_net_admin,cap_net_raw=ep). The
ubridge `brctl` module has a 168-test suite covering kernel-side VLAN
behaviour on this kernel.

View File

@ -16,6 +16,10 @@
"""
API routes for Ethernet switch nodes.
The Ethernet switch is a builtin node backed by a Linux kernel bridge driven
through uBridge's ``brctl`` module (see
``gns3server.compute.builtin.nodes.ethernet_switch``).
"""
import os
@ -25,8 +29,8 @@ from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse
from uuid import UUID
from gns3server.compute.dynamips import Dynamips
from gns3server.compute.dynamips.nodes.ethernet_switch import EthernetSwitch
from gns3server.compute.builtin import Builtin
from gns3server.compute.builtin.nodes.ethernet_switch import EthernetSwitch
from gns3server import schemas
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Ethernet switch node"}}
@ -39,8 +43,8 @@ def dep_node(project_id: UUID, node_id: UUID) -> EthernetSwitch:
Dependency to retrieve a node.
"""
dynamips_manager = Dynamips.instance()
node = dynamips_manager.get_node(str(node_id), project_id=str(project_id))
builtin_manager = Builtin.instance()
node = builtin_manager.get_node(str(node_id), project_id=str(project_id))
return node
@ -55,10 +59,9 @@ async def create_ethernet_switch(project_id: UUID, node_data: schemas.EthernetSw
Create a new Ethernet switch.
"""
# Use the Dynamips Ethernet switch to simulate this node
dynamips_manager = Dynamips.instance()
builtin_manager = Builtin.instance()
node_data = jsonable_encoder(node_data, exclude_unset=True)
node = await dynamips_manager.create_node(
node = await builtin_manager.create_node(
node_data.pop("name"),
str(project_id),
node_data.get("node_id"),
@ -67,7 +70,7 @@ async def create_ethernet_switch(project_id: UUID, node_data: schemas.EthernetSw
node_type="ethernet_switch",
ports=node_data.get("ports_mapping"),
)
node.usage = node_data.get("usage", "")
return node.asdict()
@ -86,7 +89,7 @@ async def duplicate_ethernet_switch(
Duplicate an Ethernet switch.
"""
new_node = await Dynamips.instance().duplicate_node(node.id, str(destination_node_id))
new_node = await Builtin.instance().duplicate_node(node.id, str(destination_node_id))
return new_node.asdict()
@ -101,7 +104,9 @@ async def update_ethernet_switch(
node_data = jsonable_encoder(node_data, exclude_unset=True)
if "name" in node_data and node.name != node_data["name"]:
await node.set_name(node_data["name"])
node.name = node_data["name"]
if "usage" in node_data:
node.usage = node_data["usage"]
if "ports_mapping" in node_data:
node.ports_mapping = node_data["ports_mapping"]
await node.update_port_settings()
@ -117,7 +122,7 @@ async def delete_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> No
Delete an Ethernet switch.
"""
await Dynamips.instance().delete_node(node.id)
await Builtin.instance().delete_node(node.id)
@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT)
@ -182,7 +187,7 @@ async def create_ethernet_switch_nio(
node: EthernetSwitch = Depends(dep_node)
) -> schemas.UDPNIO:
nio = await Dynamips.instance().create_nio(node, jsonable_encoder(nio_data, exclude_unset=True))
nio = Builtin.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True))
await node.add_nio(nio, port_number)
return nio.asdict()
@ -199,8 +204,7 @@ async def delete_ethernet_switch_nio(
The adapter number on the switch is always 0.
"""
nio = await node.remove_nio(port_number)
await nio.delete()
await node.remove_nio(port_number)
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
@ -251,5 +255,5 @@ async def stream_pcap_file(
"""
nio = node.get_nio(port_number)
stream = Dynamips.instance().stream_pcap_file(nio, node.project.id)
stream = Builtin.instance().stream_pcap_file(nio, node.project.id)
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")

View File

@ -14,14 +14,45 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
"""
Ethernet switch backed by a Linux kernel bridge driven through uBridge's
``brctl`` module.
The historical GNS3 Ethernet switch was an emulated L2 device inside Dynamips
(``ethsw``). This implementation replaces it with a *real* Linux kernel bridge:
one bridge per switch node, managed over uBridge's hypervisor socket. Each
switch port is a persistent TAP that plays two roles at once -- uBridge holds
its file descriptor as a ``nio_tap`` relay endpoint, and the same TAP is
enslaved to the kernel bridge as a port. This dual-role TAP is exactly the
pattern the Cloud node already uses for host bridges (see
``cloud.py::_add_linux_ethernet``).
Data path (UDP link mode)::
peer --UDP-- ubridge[nio_udp <-> nio_tap(tap)] --tap-- kernel bridge --tap-- ... (other ports)
The kernel bridge performs MAC learning/forwarding and VLAN filtering; uBridge
is only the per-port UDP transport (uBridge is strictly a 2-NIO pipe, it cannot
be the switch). ESW ``access``/``dot1q``/``qinq`` port modes are composed from
the ``brctl`` VLAN primitives here -- see ``_apply_port_vlan``.
"""
from ...base_node import BaseNode
from ...nios.nio_udp import NIOUDP
from ...error import NodeError
from gns3server.compute.ubridge.ubridge_error import UbridgeError
import logging
log = logging.getLogger(__name__)
# VLAN ethertypes the Linux kernel bridge can realise. ``brctl setvlanproto``
# accepts only 0x8100 (802.1Q) and 0x88a8 (802.1ad). The GNS3 schema also allows
# the legacy 0x9100/0x9200 QinQ ethertypes; the kernel bridge cannot do those, so
# configuring them on a qinq port is rejected.
_SUPPORTED_VLAN_ETHERTYPE = {"0x8100", "0x88a8"}
_QINQ_ETHERTYPE = "0x88a8"
class EthernetSwitch(BaseNode):
@ -32,11 +63,101 @@ class EthernetSwitch(BaseNode):
:param node_id: Node identifier
:param project: Project instance
:param manager: Parent VM Manager
:param ports: initial switch ports
"""
def __init__(self, name, node_id, project, manager):
def __init__(self, name, node_id, project, manager, console=None, console_type=None, ports=None):
super().__init__(name, node_id, project, manager)
super().__init__(name, node_id, project, manager, console=console, console_type=console_type or "none")
# The switch has no console; ``console_type="none"`` makes BaseNode skip
# reserving a TCP console port entirely.
self._ubridge_require_privileged_access = True
self._nios = {}
self._tap_by_port = {} # port_number -> kernel TAP enslaved to the bridge
self._bridge_name = None # kernel bridge interface name (allocated on start)
self._bridge_created = False
self._bridge_proto_set = False # whether ``brctl setvlanproto`` has been applied
# Idempotency flag for start(). Decoupled from ``status`` so the node can
# report "started" (always-on, like the ESW) while ``duplicate_node`` still
# sees status "stopped" and refuses only genuinely running stateful nodes.
self._started = False
if ports is None:
# 8 access ports in VLAN 1 by default, matching the historical ESW.
self._ports_mapping = []
for port_number in range(0, 8):
self._ports_mapping.append(
{"port_number": port_number, "name": f"Ethernet{port_number}", "type": "access", "vlan": 1}
)
else:
self._ports_mapping = self._normalize_ports(ports)
# ------------------------------------------------------------------ #
# helpers
# ------------------------------------------------------------------ #
@staticmethod
def _normalize_ports(ports):
"""Assign sequential port numbers/names like the Dynamips ESW did."""
port_number = 0
normalized = []
for port in ports:
port = dict(port)
port["name"] = f"Ethernet{port_number}"
port["port_number"] = port_number
normalized.append(port)
port_number += 1
return normalized
def _ubridge_bridge_name(self, port_number):
"""Name of the per-port uBridge relay bridge (not a kernel interface)."""
return f"{self._id}-{port_number}"
def _tap_name(self, port_number):
"""Kernel TAP name for a port: ``<bridge>-<port>`` (host-unique via the bridge)."""
return f"{self._bridge_name}-{port_number}"
def _port_settings(self, port_number):
for port in self._ports_mapping:
if port["port_number"] == port_number:
return port
return None
# ------------------------------------------------------------------ #
# properties / serialisation
# ------------------------------------------------------------------ #
@property
def nios(self):
return self._nios
@property
def ports_mapping(self):
return self._ports_mapping
@ports_mapping.setter
def ports_mapping(self, ports):
if ports != self._ports_mapping:
if len(self._nios) > 0 and len(ports) != len(self._ports_mapping):
raise NodeError("Cannot change the port count of a switch that is already connected.")
self._ports_mapping = self._normalize_ports(ports)
@property
def console(self):
return self._console
@console.setter
def console(self, console):
self._console = console
@property
def console_type(self):
return self._console_type
@console_type.setter
def console_type(self, console_type):
self._console_type = console_type
def asdict(self):
@ -44,61 +165,375 @@ class EthernetSwitch(BaseNode):
"name": self.name,
"usage": self.usage,
"node_id": self.id,
"project_id": self.project.id
"project_id": self.project.id,
"ports_mapping": self._ports_mapping,
"console": self.console,
"console_type": self.console_type,
# The switch is always-on once created (a kernel bridge), like the ESW.
"status": "started",
}
# ------------------------------------------------------------------ #
# lifecycle
# ------------------------------------------------------------------ #
async def create(self):
"""
Creates this switch.
"""
super().create()
await self.start()
log.info(f'Ethernet switch "{self._name}" [{self._id}] has been created')
async def start(self):
"""
Starts this switch: bring up uBridge, create the kernel bridge, and
re-wire any ports already bound before a restart.
"""
if not self._started:
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
await self._stop_ubridge()
await self._start_ubridge(self._ubridge_require_privileged_access)
await self._ensure_bridge()
for port_number in self._nios:
if self._nios[port_number]:
try:
await self._add_ubridge_connection(self._nios[port_number], port_number)
except (UbridgeError, NodeError) as e:
self._started = False
raise e
self._started = True
async def _ensure_bridge(self):
"""
Creates the per-node kernel bridge once and enables VLAN filtering.
Applies the bridge-level QinQ ethertype if any port needs it.
The bridge name is deterministic: ``gns3`` + the first 6 hex chars of
this switch's UUID (kernel interface names are ≤ 15 chars). A stale
bridge from a previous crash is deleted first so ``brctl create`` never
hits EEXIST.
"""
if self._bridge_created:
return
# deterministic short name — 10 chars, always fits the 15-char kernel cap
self._bridge_name = "gns3" + self._id.replace("-", "")[:6]
# crash recovery: best-effort delete any leftover bridge
try:
await self._ubridge_send(f'brctl delete "{self._bridge_name}"')
except UbridgeError:
pass # not found = nothing to clean
await self._ubridge_send(f'brctl create "{self._bridge_name}"')
# ``brctl create`` leaves the bridge DOWN; bring it UP so it forwards.
await self._ubridge_send(f'link set "{self._bridge_name}" up')
await self._ubridge_send(f'brctl vlanfiltering "{self._bridge_name}" on')
self._bridge_created = True
await self._apply_bridge_proto_if_needed()
async def _apply_bridge_proto_if_needed(self):
"""
If any port is a QinQ port using the 802.1ad ethertype (0x88a8), switch
the whole bridge to that protocol. A Linux bridge has a single VLAN
protocol, so mixed QinQ ethertypes within one switch are not supported.
"""
proto = None
for port in self._ports_mapping:
if port.get("type") == "qinq":
# normalise case: the schema carries uppercase (e.g. "0x88A8") but
# brctl setvlanproto wants lowercase hex
ethertype = port.get("ethertype", "0x8100").lower()
if ethertype not in _SUPPORTED_VLAN_ETHERTYPE:
raise NodeError(
f"VLAN ethertype {ethertype} is not supported by the Linux bridge "
f"(only 0x8100/0x88a8) for QinQ port {port['name']}"
)
if ethertype == _QINQ_ETHERTYPE:
proto = _QINQ_ETHERTYPE
if proto and not self._bridge_proto_set:
await self._ubridge_send(f'brctl setvlanproto "{self._bridge_name}" {proto}')
self._bridge_proto_set = True
async def delete(self):
"""
Deletes this switch.
"""
raise NotImplementedError()
return await self.close()
async def close(self):
"""
Closes this switch: release UDP ports, tear down the kernel bridge, stop uBridge.
"""
if not (await super().close()):
return False
for nio in self._nios.values():
if nio and isinstance(nio, NIOUDP):
self.manager.port_manager.release_udp_port(nio.lport, self._project)
self._nios.clear()
self._tap_by_port.clear()
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running() and self._bridge_created:
try:
# Deleting the bridge releases its enslaved TAPs; uBridge destroys
# them when it stops below.
await self._ubridge_send(f'brctl delete "{self._bridge_name}"')
except UbridgeError as e:
log.warning(f'Could not delete kernel bridge "{self._bridge_name}": {e}')
self._bridge_created = False
self._bridge_proto_set = False
self._bridge_name = None
self._started = False
await self._stop_ubridge()
log.info(f'Ethernet switch "{self._name}" [{self._id}] has been closed')
return True
# ------------------------------------------------------------------ #
# per-port wiring
# ------------------------------------------------------------------ #
async def add_nio(self, nio, port_number):
"""
Adds a NIO as new port on this switch.
Adds a NIO as a new port on this switch.
:param nio: NIO instance to add
:param port_number: port to allocate for the NIO
"""
raise NotImplementedError()
if port_number in self._nios:
raise NodeError(f"Port {port_number} isn't free")
if not isinstance(nio, NIOUDP):
raise NodeError("Ethernet switch ports only support UDP NIOs")
log.info(
'Ethernet switch "{name}" [{id}]: NIO {nio} bound to port {port}'.format(
name=self._name, id=self._id, nio=nio, port=port_number
)
)
try:
await self.start()
await self._add_ubridge_connection(nio, port_number)
self._nios[port_number] = nio
except (NodeError, UbridgeError) as e:
log.error('Cannot add NIO on Ethernet switch "{name}": {error}'.format(name=self._name, error=e))
await self._stop_ubridge()
self.status = "stopped"
self._nios[port_number] = nio
self.project.emit("log.error", {"message": str(e)})
async def _add_ubridge_connection(self, nio, port_number):
"""
Wires one port: a per-port uBridge relay (nio_tap <-> nio_udp) whose TAP
is enslaved to the kernel bridge, with the port's VLAN mode applied.
"""
port_settings = self._port_settings(port_number)
if port_settings is None:
raise NodeError(f"Port {port_number} doesn't exist on Ethernet switch '{self.name}'")
ubridge_bridge = self._ubridge_bridge_name(port_number)
tap = self._tap_name(port_number)
# per-port uBridge relay -- uBridge holds the TAP fd
await self._ubridge_send(f"bridge create {ubridge_bridge}")
await self._ubridge_send(f'bridge add_nio_tap {ubridge_bridge} "{tap}"')
# enslave the same TAP to the kernel bridge (the cloud.py::_add_linux_ethernet move)
await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{tap}"')
# VLAN membership for this port's access/trunk/qinq mode
await self._apply_port_vlan(port_settings, tap)
# GNS3 link endpoint
await self._ubridge_send(
"bridge add_nio_udp {name} {lport} {rhost} {rport}".format(
name=ubridge_bridge, lport=nio.lport, rhost=nio.rhost, rport=nio.rport
)
)
await self._ubridge_apply_filters(ubridge_bridge, nio.filters)
await self._ubridge_apply_markers(ubridge_bridge, nio)
if nio.capturing:
await self._ubridge_send(
'bridge start_capture {name} "{output_file}"'.format(
name=ubridge_bridge, output_file=nio.pcap_output_file
)
)
await self._ubridge_send(f"bridge start {ubridge_bridge}")
self._tap_by_port[port_number] = tap
async def _delete_ubridge_connection(self, port_number):
"""
Tears down one port's wiring: release the TAP from the bridge and delete
the per-port uBridge relay.
"""
tap = self._tap_by_port.pop(port_number, None)
ubridge_bridge = self._ubridge_bridge_name(port_number)
if tap is not None and self._bridge_created:
try:
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"')
except UbridgeError as e:
log.warning(f'Could not remove TAP "{tap}" from bridge "{self._bridge_name}": {e}')
try:
await self._ubridge_send(f"bridge delete {ubridge_bridge}")
except UbridgeError as e:
log.warning(f"Could not delete uBridge bridge {ubridge_bridge}: {e}")
async def remove_nio(self, port_number):
"""
Removes the specified NIO as member of this switch.
Removes the specified NIO from this switch.
:param port_number: allocated port number
:returns: the NIO that was bound to the allocated port
"""
raise NotImplementedError()
if port_number not in self._nios:
raise NodeError(f"Port {port_number} is not allocated")
await self.stop_capture(port_number)
nio = self._nios[port_number]
if isinstance(nio, NIOUDP):
self.manager.port_manager.release_udp_port(nio.lport, self._project)
log.info(
'Ethernet switch "{name}" [{id}]: NIO {nio} removed from port {port}'.format(
name=self._name, id=self._id, nio=nio, port=port_number
)
)
del self._nios[port_number]
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
await self._delete_ubridge_connection(port_number)
return nio
def get_nio(self, port_number):
"""
Gets a port NIO binding.
:param port_number: port number
:returns: NIO instance
"""
if port_number not in self._nios:
raise NodeError(f"Port {port_number} is not connected")
return self._nios[port_number]
async def update_nio(self, port_number, nio):
"""
Re-applies uBridge filters/markers for a port (called when a link is updated).
"""
ubridge_bridge = self._ubridge_bridge_name(port_number)
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
await self._ubridge_apply_filters(ubridge_bridge, nio.filters)
await self._ubridge_apply_markers(ubridge_bridge, nio)
# ------------------------------------------------------------------ #
# VLAN mode translation
# ------------------------------------------------------------------ #
async def _reset_port_vlan(self, tap):
"""
Resets a port's VLAN membership to the kernel default (PVID 1, untagged)
by re-enslaving it. Used before re-applying a changed mode so stale VIDs
from the previous mode do not leak.
"""
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"')
await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{tap}"')
async def _apply_port_vlan(self, port_settings, tap):
"""
Translates an ESW port mode into ``brctl`` VLAN primitives. The port must
already be enslaved to the bridge and carry the default PVID 1.
- access VLAN N: drop default 1, add N as PVID + egress untagged.
- dot1q trunk (native N): drop default 1, admit all VIDs tagged, then mark
the native VLAN PVID + untagged. (The ESW model declares only the native
VLAN per trunk port, so the trunk admits all VIDs, like the emulated ESW.)
- qinq (outer N): the bridge-level protocol is set separately; the port
gets the service VLAN as PVID + untagged so customer frames are S-tagged.
"""
br = self._bridge_name
port_type = port_settings["type"]
vlan = int(port_settings["vlan"])
if port_type == "access":
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
elif port_type == "dot1q":
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" 1 vid 4094')
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
elif port_type == "qinq":
# setvlanproto is applied at the bridge level by _apply_bridge_proto_if_needed
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
else:
raise NodeError(f"Unknown port type '{port_type}' on Ethernet switch '{self.name}'")
async def update_port_settings(self):
"""
Re-applies port settings (called after ``ports_mapping`` is updated). For
ports already wired, reset then re-apply so a mode/VLAN change fully
replaces the previous VLAN membership.
"""
await self._apply_bridge_proto_if_needed()
if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running() and self._bridge_created):
return
for port_settings in self._ports_mapping:
port_number = port_settings["port_number"]
tap = self._tap_by_port.get(port_number)
if tap is None:
continue
await self._reset_port_vlan(tap)
await self._apply_port_vlan(port_settings, tap)
# ------------------------------------------------------------------ #
# capture
# ------------------------------------------------------------------ #
async def start_capture(self, port_number, output_file, data_link_type="DLT_EN10MB"):
"""
Starts a packet capture.
Starts a packet capture on a port (uBridge captures on the per-port relay).
:param port_number: allocated port number
:param output_file: PCAP destination file for the capture
:param data_link_type: PCAP data link type (DLT_*), default is DLT_EN10MB
"""
raise NotImplementedError()
nio = self.get_nio(port_number)
if nio.capturing:
raise NodeError(f"Packet capture is already activated on port {port_number}")
nio.start_packet_capture(output_file)
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
ubridge_bridge = self._ubridge_bridge_name(port_number)
await self._ubridge_send(f'bridge start_capture {ubridge_bridge} "{output_file}"')
log.info(
'Ethernet switch "{name}" [{id}]: starting packet capture on port {port}'.format(
name=self.name, id=self.id, port=port_number
)
)
async def stop_capture(self, port_number):
"""
Stops a packet capture.
Stops a packet capture on a port.
:param port_number: allocated port number
"""
raise NotImplementedError()
nio = self.get_nio(port_number)
if not nio.capturing:
return
nio.stop_packet_capture()
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
ubridge_bridge = self._ubridge_bridge_name(port_number)
await self._ubridge_send(f"bridge stop_capture {ubridge_bridge}")
log.info(
'Ethernet switch "{name}" [{id}]: stopping packet capture on port {port}'.format(
name=self.name, id=self.id, port=port_number
)
)

View File

@ -21,34 +21,48 @@ import pytest_asyncio
from fastapi import FastAPI, status
from httpx import AsyncClient
from tests.utils import asyncio_patch, AsyncioMagicMock
from unittest.mock import call
from unittest.mock import call, MagicMock
from gns3server.compute.project import Project
# The builtin Ethernet switch talks to uBridge (brctl/bridge modules) instead of
# the Dynamips hypervisor. These are the seams we stub so the routes can be
# exercised without launching a real uBridge / creating kernel interfaces.
_NODE = "gns3server.compute.builtin.nodes.ethernet_switch.EthernetSwitch"
pytestmark = pytest.mark.asyncio
class TestEthernetSwitchNodesRoutes:
@pytest_asyncio.fixture(autouse=True)
async def stub_ubridge(self):
"""Keep uBridge from really starting and capture every command."""
with asyncio_patch(f"{_NODE}._start_ubridge"), asyncio_patch(f"{_NODE}._stop_ubridge"), \
asyncio_patch(f"{_NODE}._ubridge_send"):
yield
@pytest_asyncio.fixture
async def ethernet_switch(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project) -> dict:
params = {"name": "Ethernet Switch"}
with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.create") as mock:
response = await compute_client.post(
app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id),
json=params
)
assert mock.called
assert response.status_code == status.HTTP_201_CREATED
response = await compute_client.post(
app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id),
json=params
)
assert response.status_code == status.HTTP_201_CREATED
json_response = response.json()
node = compute_project.get_node(json_response["node_id"])
node._hypervisor = AsyncioMagicMock()
node._hypervisor.send = AsyncioMagicMock()
node._hypervisor.version = "0.2.16"
# Pretend uBridge is up so the is_running() guards in remove/close pass.
node._ubridge_hypervisor = MagicMock()
node._ubridge_hypervisor.is_running.return_value = True
node._ubridge_send.reset_mock()
return json_response
@staticmethod
def _udp_params() -> dict:
return {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}
async def test_ethernet_switch_create(
self, app: FastAPI,
@ -57,16 +71,24 @@ class TestEthernetSwitchNodesRoutes:
) -> None:
params = {"name": "Ethernet Switch 1"}
with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.create") as mock:
response = await compute_client.post(
app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id),
json=params
)
assert mock.called
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["name"] == "Ethernet Switch 1"
assert response.json()["project_id"] == compute_project.id
response = await compute_client.post(
app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id),
json=params
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["name"] == "Ethernet Switch 1"
assert response.json()["project_id"] == compute_project.id
assert response.json()["status"] == "started"
# creation stands up the kernel bridge with VLAN filtering
node = compute_project.get_node(response.json()["node_id"])
br = node._bridge_name
node._ubridge_send.assert_has_calls([
call(f'brctl delete "{br}"'),
call(f'brctl create "{br}"'),
call(f'link set "{br}" up'),
call(f'brctl vlanfiltering "{br}" on'),
])
async def test_ethernet_switch_get(
self, app: FastAPI,
@ -87,7 +109,6 @@ class TestEthernetSwitchNodesRoutes:
assert response.json()["project_id"] == compute_project.id
assert response.json()["status"] == "started"
async def test_ethernet_switch_duplicate(
self,
app: FastAPI,
@ -98,15 +119,11 @@ class TestEthernetSwitchNodesRoutes:
# create destination switch first
params = {"name": "Ethernet Switch 2"}
with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.create") as mock:
response = await compute_client.post(
app.url_path_for(
"compute:create_ethernet_switch",
project_id=compute_project.id),
json=params
)
assert mock.called
assert response.status_code == status.HTTP_201_CREATED
response = await compute_client.post(
app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id),
json=params
)
assert response.status_code == status.HTTP_201_CREATED
params = {"destination_node_id": response.json()["node_id"]}
response = await compute_client.post(
@ -117,7 +134,6 @@ class TestEthernetSwitchNodesRoutes:
)
assert response.status_code == status.HTTP_201_CREATED
async def test_ethernet_switch_update(
self,
app: FastAPI,
@ -126,10 +142,7 @@ class TestEthernetSwitchNodesRoutes:
ethernet_switch: dict
) -> None:
params = {
"name": "test",
"console_type": "telnet"
}
params = {"name": "test", "console_type": "none"}
response = await compute_client.put(
app.url_path_for(
@ -141,11 +154,12 @@ class TestEthernetSwitchNodesRoutes:
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == "test"
# renaming a builtin switch does not touch uBridge (the kernel bridge is
# name-independent); nothing should have been sent.
node = compute_project.get_node(ethernet_switch["node_id"])
node._hypervisor.send.assert_called_with("ethsw rename \"Ethernet Switch\" \"test\"")
node._ubridge_send.assert_not_called()
async def test_ethernet_switch_update_ports(
async def test_ethernet_switch_update_ports_qinq_proto(
self,
app: FastAPI,
compute_client: AsyncClient,
@ -153,33 +167,11 @@ class TestEthernetSwitchNodesRoutes:
ethernet_switch: dict
) -> None:
# a QinQ port with the 802.1ad ethertype must switch the bridge protocol
port_params = {
"ports_mapping": [
{
"name": "Ethernet0",
"port_number": 0,
"type": "qinq",
"vlan": 1
},
{
"name": "Ethernet1",
"port_number": 1,
"type": "qinq",
"vlan": 2,
"ethertype": "0x88A8"
},
{
"name": "Ethernet2",
"port_number": 2,
"type": "dot1q",
"vlan": 3,
},
{
"name": "Ethernet3",
"port_number": 3,
"type": "access",
"vlan": 4,
}
{"name": "Ethernet0", "port_number": 0, "type": "qinq", "vlan": 2, "ethertype": "0x88A8"},
{"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 4},
],
}
@ -192,90 +184,20 @@ class TestEthernetSwitchNodesRoutes:
)
assert response.status_code == status.HTTP_200_OK
nio_params = {
"type": "nio_udp",
"lport": 4242,
"rport": 4343,
"rhost": "127.0.0.1"
}
for port_mapping in port_params["ports_mapping"]:
port_number = port_mapping["port_number"]
vlan = port_mapping["vlan"]
port_type = port_mapping["type"]
ethertype = port_mapping.get("ethertype", "")
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number=f"{port_number}"
)
await compute_client.post(url, json=nio_params)
node = compute_project.get_node(ethernet_switch["node_id"])
nio = node.get_nio(port_number)
calls = [
call.send(f'nio create_udp {nio.name} 4242 127.0.0.1 4343'),
call.send(f'ethsw add_nio "Ethernet Switch" {nio.name}'),
call.send(f'ethsw set_{port_type}_port "Ethernet Switch" {nio.name} {vlan} {ethertype}'.strip())
]
node._hypervisor.send.assert_has_calls(calls)
node._hypervisor.send.reset_mock()
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.assert_any_call(f'brctl setvlanproto "{node._bridge_name}" 0x88a8')
@pytest.mark.parametrize(
"ports_settings",
(
(
{
"name": "Ethernet0",
"port_number": 0,
"type": "dot42q", # invalid port type
"vlan": 1,
}
),
(
{
"name": "Ethernet0",
"port_number": 0,
"type": "access", # missing vlan field
}
),
(
{
"name": "Ethernet0",
"port_number": 0,
"type": "dot1q",
"vlan": 1,
"ethertype": "0x88A8" # EtherType is only for QinQ
}
),
(
{
"name": "Ethernet0",
"port_number": 0,
"type": "qinq",
"vlan": 1,
"ethertype": "0x4242" # not a valid EtherType
}
),
(
{
"name": "Ethernet0",
"port_number": 0,
"type": "access",
"vlan": 0, # minimum vlan number is 1
}
),
(
{
"name": "Ethernet0",
"port_number": 0,
"type": "access",
"vlan": 4242, # maximum vlan number is 4094
}
),
{"name": "Ethernet0", "port_number": 0, "type": "dot42q", "vlan": 1}, # bad type
{"name": "Ethernet0", "port_number": 0, "type": "access"}, # missing vlan
{"name": "Ethernet0", "port_number": 0, "type": "dot1q", "vlan": 1,
"ethertype": "0x88A8"}, # ethertype only for qinq
{"name": "Ethernet0", "port_number": 0, "type": "qinq", "vlan": 1,
"ethertype": "0x4242"}, # bad ethertype
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 0}, # vlan < 1
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 4242}, # vlan > 4094
)
)
async def test_ethernet_switch_update_ports_invalid(
@ -286,20 +208,15 @@ class TestEthernetSwitchNodesRoutes:
ports_settings: dict,
) -> None:
port_params = {
"ports_mapping": [ports_settings]
}
response = await compute_client.put(
app.url_path_for(
"compute:update_ethernet_switch",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"]),
json=port_params
json={"ports_mapping": [ports_settings]}
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
async def test_ethernet_switch_delete(
self, app: FastAPI,
compute_client: AsyncClient,
@ -315,12 +232,7 @@ class TestEthernetSwitchNodesRoutes:
)
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_ethernet_switch_start(
self, app: FastAPI,
compute_client: AsyncClient,
ethernet_switch: dict
) -> None:
async def test_ethernet_switch_start(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None:
response = await compute_client.post(
app.url_path_for(
@ -331,12 +243,7 @@ class TestEthernetSwitchNodesRoutes:
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
async def test_ethernet_switch_stop(
self, app: FastAPI,
compute_client: AsyncClient,
ethernet_switch: dict
) -> None:
async def test_ethernet_switch_stop(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None:
response = await compute_client.post(
app.url_path_for(
@ -347,12 +254,7 @@ class TestEthernetSwitchNodesRoutes:
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
async def test_ethernet_switch_suspend(
self, app: FastAPI,
compute_client: AsyncClient,
ethernet_switch: dict
) -> None:
async def test_ethernet_switch_suspend(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None:
response = await compute_client.post(
app.url_path_for(
@ -363,12 +265,7 @@ class TestEthernetSwitchNodesRoutes:
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
async def test_ethernet_switch_reload(
self, app: FastAPI,
compute_client: AsyncClient,
ethernet_switch: dict
) -> None:
async def test_ethernet_switch_reload(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None:
response = await compute_client.post(
app.url_path_for(
@ -379,8 +276,7 @@ class TestEthernetSwitchNodesRoutes:
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
async def test_ethernet_switch_create_udp(
async def test_ethernet_switch_create_udp_access(
self,
app: FastAPI,
compute_client: AsyncClient,
@ -388,13 +284,6 @@ class TestEthernetSwitchNodesRoutes:
ethernet_switch: dict
) -> None:
params = {
"type": "nio_udp",
"lport": 4242,
"rport": 4343,
"rhost": "127.0.0.1"
}
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
@ -402,19 +291,66 @@ class TestEthernetSwitchNodesRoutes:
adapter_number="0",
port_number="0"
)
response = await compute_client.post(url, json=params)
response = await compute_client.post(url, json=self._udp_params())
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["type"] == "nio_udp"
node = compute_project.get_node(ethernet_switch["node_id"])
nio = node.get_nio(0)
calls = [
call.send(f'nio create_udp {nio.name} 4242 127.0.0.1 4343'),
call.send(f'ethsw add_nio "Ethernet Switch" {nio.name}'),
call.send(f'ethsw set_access_port "Ethernet Switch" {nio.name} 1')
]
node._hypervisor.send.assert_has_calls(calls)
br = node._bridge_name
tap = f"{br}-0"
relay = f"{node.id}-0"
# access VLAN 1 (default): drop default PVID 1, re-add 1 as PVID/untagged
node._ubridge_send.assert_has_calls([
call(f"bridge create {relay}"),
call(f'bridge add_nio_tap {relay} "{tap}"'),
call(f'brctl addif "{br}" "{tap}"'),
call(f'brctl vlan_del "{br}" "{tap}" 1'),
call(f'brctl vlan_add "{br}" "{tap}" 1 pvid untagged'),
call(f"bridge add_nio_udp {relay} {nio.lport} {nio.rhost} {nio.rport}"),
call(f"bridge reset_packet_filters {relay}"),
call(f"bridge start {relay}"),
])
async def test_ethernet_switch_create_udp_dot1q(
self,
app: FastAPI,
compute_client: AsyncClient,
compute_project: Project,
ethernet_switch: dict
) -> None:
# make port 0 a dot1q trunk with native VLAN 10
await compute_client.put(
app.url_path_for(
"compute:update_ethernet_switch",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"]),
json={"ports_mapping": [
{"name": "Ethernet0", "port_number": 0, "type": "dot1q", "vlan": 10},
]}
)
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0"
)
response = await compute_client.post(url, json=self._udp_params())
assert response.status_code == status.HTTP_201_CREATED
br = node._bridge_name
tap = f"{br}-0"
# trunk: drop default 1, admit all VIDs tagged, mark native 10 PVID/untagged
node._ubridge_send.assert_has_calls([
call(f'brctl vlan_del "{br}" "{tap}" 1'),
call(f'brctl vlan_add "{br}" "{tap}" 1 vid 4094'),
call(f'brctl vlan_add "{br}" "{tap}" 10 pvid untagged'),
])
async def test_ethernet_switch_delete_nio(
self,
@ -424,13 +360,6 @@ class TestEthernetSwitchNodesRoutes:
ethernet_switch: dict
) -> None:
params = {
"type": "nio_udp",
"lport": 4242,
"rport": 4343,
"rhost": "127.0.0.1"
}
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
@ -438,11 +367,10 @@ class TestEthernetSwitchNodesRoutes:
adapter_number="0",
port_number="0"
)
await compute_client.post(url, json=params)
await compute_client.post(url, json=self._udp_params())
node = compute_project.get_node(ethernet_switch["node_id"])
node._hypervisor.send.reset_mock()
nio = node.get_nio(0)
node._ubridge_send.reset_mock()
url = app.url_path_for(
"compute:delete_ethernet_switch_nio",
@ -454,52 +382,86 @@ class TestEthernetSwitchNodesRoutes:
response = await compute_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
calls = [
call(f'ethsw remove_nio "Ethernet Switch" {nio.name}'),
call(f'nio delete {nio.name}')
]
node._hypervisor.send.assert_has_calls(calls)
br = node._bridge_name
tap = f"{br}-0"
relay = f"{node.id}-0"
node._ubridge_send.assert_has_calls([
call(f'brctl delif "{br}" "{tap}"'),
call(f"bridge delete {relay}"),
])
async def test_ethernet_switch_start_capture(
self,
app: FastAPI,
compute_client: AsyncClient,
compute_project: Project,
ethernet_switch: dict
) -> None:
params = {
"capture_file_name": "test.pcap",
"data_link_type": "DLT_EN10MB"
}
# capture needs a wired port
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0"
)
await compute_client.post(url, json=self._udp_params())
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
params = {"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"}
url = app.url_path_for("compute:start_ethernet_switch_capture",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0")
with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.start_capture") as mock:
response = await compute_client.post(url, json=params)
assert response.status_code == status.HTTP_200_OK
assert mock.called
assert "test.pcap" in response.json()["pcap_file_path"]
response = await compute_client.post(url, json=params)
assert response.status_code == status.HTTP_200_OK
assert "test.pcap" in response.json()["pcap_file_path"]
relay = f"{node.id}-0"
node._ubridge_send.assert_any_call(f'bridge start_capture {relay} "{node.get_nio(0).pcap_output_file}"')
async def test_ethernet_switch_stop_capture(
self,
app: FastAPI,
compute_client: AsyncClient,
compute_project: Project,
ethernet_switch: dict
) -> None:
url = app.url_path_for("compute:stop_ethernet_switch_capture",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0")
# start a capture first
await compute_client.post(
app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0"
),
json=self._udp_params()
)
await compute_client.post(
app.url_path_for("compute:start_ethernet_switch_capture",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0"),
json={"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"}
)
with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.stop_capture") as mock:
response = await compute_client.post(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert mock.called
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
relay = f"{node.id}-0"
response = await compute_client.post(
app.url_path_for("compute:stop_ethernet_switch_capture",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0")
)
assert response.status_code == status.HTTP_204_NO_CONTENT
node._ubridge_send.assert_any_call(f"bridge stop_capture {relay}")