YueGuobin 90bcb32473
feat(esw): cascade two switches through a veth pair
Switch-to-switch links were the last shape left on the relay: the switch
fast path works by absorbing the *peer's* anchor, and a switch has none —
its ports are its own bridge, and a kernel interface belongs to exactly
one bridge, so two bridges cannot be joined by enslaving anything.

Join them with a veth pair instead: one end per bridge, each side
enslaving its own end and applying its own port mode to it — an access
port on one side and a trunk on the other behaves like the real cable it
models. uBridge's docker create_veth/delete_veth (the primitive every
Docker adapter is already born from) creates and destroys the pair; the
names derive from the link id (utils.kernel_anchor.kernel_cascade_names),
so each side computes its own end and the names survive a restart.

The pair belongs to the link, and the two ends are wired by two
independent uBridge processes — the project-open batch drives different
nodes concurrently — so the protocol is order-free: both sides create the
pair when their own end is missing (one create wins; the loser's failure
is re-checked against the kernel and "the pair is there" is not an
error), and either side's teardown destroys it (deleting one veth end
takes the other with it, so both converge on the same gone pair and the
second delif answers ENOENT). A pre-existing end — this switch restarted,
or a leftover from a previous life of the link id — is reused rather than
recreated, because the other end may still be a live port of the peer
switch; a stale tc qdisc is all a reused end can carry.

No new wire format was needed: a cascade is an anchor NIO on both ends,
the absorbed-anchor case plus the peer end's name. That also corrects the
filter routing — the single-sided rule exists because an absorbed anchor
is one interface, while a cascade has one per switch, so impairments go
to both ends like any other kernel link.

Tests: the wire format, the eligibility flip and the both-ends re-push on
node start (controller); the naming contract (utils); the switch-side
lifecycle — create, reuse, lost create race, delete destroys the pair
(compute routes).

Drive-by: the relay filter test's compute mock carried no awaitable
`put`, so tests/controller/test_kernel_datapath_link.py failed when run
alone while an argument-less full run passed; callers now get one.
2026-10-06 00:21:09 +08:00

182 lines
6.4 KiB
Python

#
# Copyright (C) 2020 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/>.
from enum import Enum
from typing import List, Optional, Union
from pydantic import BaseModel, Field
class UDPNIOType(str, Enum):
udp = "nio_udp"
class UDPNIO(BaseModel):
"""
UDP Network Input/Output properties.
"""
type: UDPNIOType
lport: int = Field(..., gt=0, le=65535, description="Local port")
rhost: str = Field(..., description="Remote host")
rport: int = Field(..., gt=0, le=65535, description="Remote port")
suspend: Optional[bool] = Field(None, description="Suspend the NIO")
filters: Optional[dict] = Field(
None,
description="Packet filters (relay-supported types only: delay/packet_loss/corrupt/frequency_drop/bpf — "
"the netem extensions and the quota mode need a kernel-datapath NIO)",
)
markers: Optional[dict] = Field(None, description="Traffic-insight markers")
class EthernetNIOType(str, Enum):
ethernet = "nio_ethernet"
class EthernetNIO(BaseModel):
"""
Generic Ethernet Network Input/Output properties.
"""
type: EthernetNIOType
ethernet_device: str = Field(..., description="Ethernet device name e.g. eth0")
class TAPNIOType(str, Enum):
tap = "nio_tap"
class TAPNIO(BaseModel):
"""
TAP Network Input/Output properties.
"""
type: TAPNIOType
tap_device: str = Field(..., description="TAP device name e.g. tap0")
class BridgeNIOType(str, Enum):
bridge = "nio_bridge"
class BridgeNIO(BaseModel):
"""
Kernel-datapath bridge Network Input/Output properties. The NIO tells the
node to enslave its veth host end into the named kernel bridge instead of
wiring a uBridge UDP relay. Impairment filters run on the veth host end:
delay/packet_loss/corrupt and the netem extensions (rate, reorder,
gemodel, duplicate, seed, limit, jitter distributions, loss correlation)
as one tc netem qdisc, bpf as cls_bpf match-drop classifiers (needs a
uBridge reporting cbpf), frequency_drop and quota as the eBPF stateful
classifier (needs ebpf). Markers and packet capture are served by
uBridge's AF_PACKET modules on the veth host end.
``bridge`` may be None: the anchor is then bridged by an Ethernet switch
(its own kernel bridge absorbed it) and the node applies only carrier,
markers and impairments — never bridge membership.
"""
type: BridgeNIOType
bridge: Optional[str] = Field(
None,
description="Kernel bridge name e.g. gns3a1b2c3d4e5; None when an Ethernet switch "
"owns the anchor's bridge membership (absorbed link)",
)
suspend: Optional[bool] = Field(None, description="Suspend the NIO")
filters: Optional[dict] = Field(
None,
description="Packet filters served on the veth host end (netem surface + extensions via one tc netem qdisc, "
"bpf via cls_bpf, frequency_drop/quota via the eBPF classifier)",
)
markers: Optional[dict] = Field(None, description="Traffic-insight markers (attached to the veth host end)")
class AnchorNIOType(str, Enum):
anchor = "nio_anchor"
class AnchorNIO(BaseModel):
"""
Anchor-absorbing Network Input/Output properties, for a node that owns a
kernel bridge (the Ethernet switch): the named foreign interface — the
peer's kernel-datapath anchor — is joined to the node's own bridge with
the node's port settings applied to it. The mirror of BridgeNIO; filters,
markers and capture ride the NIO and attach to the named anchor.
"""
type: AnchorNIOType
anchor: str = Field(..., description="Host interface to absorb e.g. gv00010203e0p0")
peer: Optional[str] = Field(
None,
description="Switch-to-switch cascade: the other end of the veth pair this port is one end of. Present on "
"exactly one side (the owner, which creates and destroys the pair); absent for an absorbed anchor",
)
suspend: Optional[bool] = Field(None, description="Suspend the NIO")
filters: Optional[dict] = Field(
None,
description="Packet filters served on the absorbed anchor (netem surface + extensions via one tc netem "
"qdisc, bpf via cls_bpf, frequency_drop/quota via the eBPF classifier)",
)
markers: Optional[dict] = Field(None, description="Traffic-insight markers (attached to the absorbed anchor)")
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
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 = ""
class BatchNIOEntry(BaseModel):
"""
A single NIO binding to create as part of a project-wide batch.
"""
node_id: str = Field(..., description="Node the NIO is attached to")
adapter_number: int = Field(0, ge=0, description="Adapter number")
port_number: int = Field(0, ge=0, description="Port number")
nio: Union[UDPNIO, BridgeNIO, AnchorNIO] = Field(..., description="NIO settings")
class BatchNIOCreate(BaseModel):
"""
Body for the project-wide batch NIO endpoint: create many NIO bindings in a
single request (used during project open) to avoid one HTTP round-trip per
NIO between controller and compute.
"""
nios: List[BatchNIOEntry] = Field(..., description="NIO bindings to create")