gns3-server/docs/features/iol-docker-kernel-datapath.md
YueGuobin 96006dcf58
docs(features): give packet filters their own doc with the evaluation-order diagram
The Packet filters section of docker-kernel-datapath.md was the de-facto
filter documentation every other datapath doc pointed at; move it to
features/packet-filters.md as the node-type-neutral home (veth or TAP
anchor), centered on a mermaid walkthrough of all 13 types through the
anchor's egress chain: clsact first (the eBPF stateful classifier's nth /
quota / window modes at prio 1, then the bpf expression lines at 10-99,
first drop wins), the single netem qdisc behind it (enqueue draws, one
time_to_send drained by the delay queue).

Clarify a naming trap the table verbs invite: uBridge's tc module only
borrows the iproute2 vocabulary — the tc ... tokens are its own hypervisor
command names over hand-built netlink messages (RTM_NEWQDISC /
RTM_NEWTFILTER), raw bpf() syscalls, embedded distribution tables and
instruction array, libpcap as a library only; the tc binary is never
invoked outside the test suite's oracles. Verified against the uBridge
sources (hypervisor_tc.c, tc_impair.bpf.c, tc_ebpf.c), not just the
commands gns3-server sends.

docker-kernel-datapath.md keeps a pointer section; the iol doc's filter
reference and docs/README.md's tree and catalog point at the new doc.
2026-10-07 22:39:39 +08:00

22 KiB
Raw Blame History

This documentation is AI-generated with reference to actual code and verified against real-kernel testing. AI can make mistakes — please verify against the source code when in doubt.

IOL runner container kernel datapath

Overview

IOL runner containers (IOLDockerVM, selected by the GNS3_IOL_RUNNER environment marker — the Cisco CML iol-runner images such as iol-xe/iol-xe:17-18-02) have no container-namespace networking at all: their guest leg is the runner's per-interface AF_UNIX datagram socket pairs. A unix socket is not a kernel interface, so until now every link of such a container rode the uBridge userspace relay (unix ↔ UDP).

The kernel datapath gives every Ethernet bay/unit a persistent TAP anchor (gx names, born at node start exactly like IOU's) and wires the link segment through the kernel. One userspace hop on the container leg is irreducible — the socket pair is the only physical layer the runner exposes — but the anchor → per-link bridge → peer anchor crossing, the kernel filters, the suspend carrier and the AF_PACKET capture/markers are all native now.

The port bridge is the deployment shape ubridge's doc/gns3server-integration.md § bridge — generic NIO relay with a swappable TAP leg froze: one bridge per bay/unit, all created at start, the unix NIO in the first slot for the node's whole life, the topology leg in the second — add_nio_udp (relay) or add_nio_tap (kernel link) — swapped through stop → delete → add → start whenever a link is deleted, re-created or switched between datapaths. Suspend swaps no leg (an in-place NIO update), and a project reopen is a fresh build from the NIO, not a swap. The c-socket binding never re-binds across a swap: frames the container sends while the bridge is stopped queue on the socket and relay after start (regression-tested in ubridge's bridge suite).

Architecture

Kernel link between two IOL runner containers on the same compute. Interface names follow the shared deterministic rules — gx{node_id[:8]}e{bay}p{unit} for the persistent TAP anchors (one per Ethernet bay/unit) and gns3{link_id[:11]} for the bridge; the concrete names in the diagrams use node ids 1a2b3c4d / 99aa88bb, Ethernet0/0 on both, and link id prefix 9a8b7c6d5e4. Terms the diagrams use:

Term Meaning
netiomux the runner's network agent: one AF_UNIX datagram socket pair per interface, carrying raw Ethernet frames
sNN.sock / cNN.sock the interface's socket pair (NN = bay×4 + unit): frames sent to s are injected into the guest, the guest's egress arrives on c
wiring dir the host-side directory bind-mounted at the container's /tmp ($XDG_RUNTIME_DIR/gns3/unixio/<node-id>) — the same socket files on both sides, short enough for the 107-byte sun_path cap
uBridge the per-node helper process gns3-server spawns; its hypervisor command surface executes all host-side wiring (anchors, bridges, carrier, tc, taps) — and, unique to this node type, it also carries frames: the socket guest leg has no kernel form
port bridge the per-bay/unit uBridge bridge (bridge{bay} on unit 0, bridge{bay}_{unit} on units 1-3 — the historical _bridge_name naming): nio_unix in its first slot for the node's whole life, the topology leg — nio_tap (kernel link) or nio_udp (relay) — in the second
anchor the persistent TAP gx… a link attaches to: born at node start, DOWN, its fd held by the port bridge while a kernel link binds the port
per-link bridge the Linux bridge gns3{link_id[:11]} that stands in for the cable; the two anchors are its only ports
bridge port an interface enslaved to a Linux bridge (brctl addif) — it no longer sends or receives on its own: frames arriving on it go to the bridge's forwarding logic, and the bridge transmits through it
FDB the bridge's forwarding database (learned MAC → port); unknown, broadcast and multicast destinations are flooded
01:80:c2 / group_fwd_mask the IEEE 802.1D reserved multicast range (STP, LACP, LLDP, 802.1X, PAUSE); the bridge forwards it only per the port's group_fwd_mask — 0xfffd passes everything except PAUSE/PFC
tc / qdisc traffic-control objects the kernel inserts into the anchor's egress path — stages frames pass through in flight, not external packet consumers; uBridge only installs them over netlink
clsact the tc classifier carrier on the anchor; its egress chain is where the drop classifiers run
eBPF classifier the stateful drop modes (every-Nth, quota, time window) at clsact egress prio 1
bpf_drop match-drop BPF expressions at clsact egress prio 10–99
netem the impairment qdisc behind clsact: delay, loss, rate, corrupt, duplicate, …
AF_PACKET tap uBridge's packet socket bound to the anchor; the kernel clones every frame the interface sends or receives to it (the tcpdump mechanism) — read-only copies for capture and markers, the original stays on the kernel path
carrier the anchor's admin state (link set up/down); suspend is carrier down on both anchors = 100 % loss
nio_bridge the NIO record the controller sends each compute: bridge name, filters, markers, suspend flag — the bridge name is None when an Ethernet-switch peer absorbs the anchor into its own kernel bridge (then no per-link bridge exists on this side)
flowchart TB
    subgraph NetA["container A netns (iol-runner)"]
        IOLA["IOL process"]
        MUXA["netiomux"]
        IOLA <-->|"Eth0/0"| MUXA
    end
    subgraph RootNS["root namespace"]
        subgraph UBA["uBridge A process"]
            PBA["port bridge A"]
        end
        gxA["gx1a2b3c4de0p0 — anchor"]
        subgraph LBR["per-link Linux bridge"]
            BR["gns39a8b7c6d5e4"]
        end
        gxB["gx99aa88bbe0p0 — anchor"]
        subgraph UBB["uBridge B process"]
            PBB["port bridge B"]
        end
    end
    subgraph NetB["container B netns (iol-runner)"]
        MUXB["netiomux"]
        IOLB["IOL process"]
        MUXB <-->|"Eth0/0"| IOLB
    end
    MUXA <-->|"nio_unix"| PBA
    PBA <-->|"nio_tap"| gxA
    gxA <-->|"bridge port"| BR
    BR <-->|"bridge port"| gxB
    gxB <-->|"nio_tap"| PBB
    PBB <-->|"nio_unix"| MUXB
    PBA -. "qdisc install" .-> gxA
    gxA -. "frame copies" .-> PBA
    PBB -. "qdisc install" .-> gxB
    gxB -. "frame copies" .-> PBB

Everything in the root namespace is built the same way: gns3-server creates the anchor TAPs at node start, the per-link bridge and its memberships at link creation, and every later change (carrier, capture, markers, filters) by sending hypervisor commands to the node's uBridge process. That build flow is the Link operations sequence below; this diagram is the steady state that remains.

The solid chain is the frame path, and it passes through the port bridges — the one userspace hop per container leg the socket pair forces, and the only one on the whole path; unlike the Docker veth datapath, where uBridge sits entirely off the forwarding path, here it is on it. The dashed edges are control and observation: qdisc install is the port bridge configuring the kernel over netlink (the kernel then runs those qdiscs in-flight on the anchor's egress — stages of the transmit path, not packet consumers), and frame copies is the AF_PACKET tap — read-only clones for capture and markers. Everything sideband is keyed on the anchor's interface name, which is why a TAP anchor and a veth host end are interchangeable in the shared mixin (gns3server/compute/kernel_datapath.py).

Relay link (what a missing capability, a cross-compute peer or enable_kernel_datapath = off falls back to — same sockets, same port bridge, only the topology leg changes):

flowchart LR
    subgraph CTA["container A (iol-runner)"]
        MUXA["netiomux A"]
    end
    subgraph KRN["kernel — sockets & loopback"]
        SOCKA["c00.sock"]
        UDP["UDP sockets (loopback)"]
        SOCKB["s00.sock"]
        ANCH["gx anchors — idle"]
    end
    subgraph UBA["uBridge A — userspace"]
        UNA["unix nio"]
        ENA["port bridge relay + userspace filters"]
        NDA["udp nio"]
    end
    subgraph UBB["uBridge B — userspace"]
        NDB["udp nio"]
        ENB["port bridge relay + userspace filters"]
        UNB["unix nio"]
    end
    subgraph CTB["container B (iol-runner)"]
        MUXB["netiomux B"]
    end
    MUXA -->|"copy 1: user→kernel"| SOCKA
    SOCKA -->|"copy 2: kernel→user"| UNA
    UNA --> ENA --> NDA
    NDA -->|"copy 3: user→kernel"| UDP
    UDP -->|"copy 4: kernel→user"| NDB
    NDB --> ENB --> UNB
    UNB -->|"copy 5: user→kernel"| SOCKB
    SOCKB -->|"copy 6: kernel→user"| MUXB

Drawn one-way; the reverse direction is the mirror image. The IOL relay crosses the kernel/user boundary six times each way — but four of them are the socket guest leg itself, paid identically on the kernel datapath (six there too): unlike Docker's four-against-zero, the crossing count is datapath-invariant here, and the choice swaps only the middle two — loopback UDP between the port bridges (filters userspace, at them) for the per-link bridge between the anchors (filters as tc, on them).

Two further contrasts with Docker's relay: the IOL relay's local leg is the unix NIO itself, so the anchors never appear on the path (idle from birth — a Docker relay attaches to the same veth host end the kernel link enslaves); and the port bridge's first slot is shared with the kernel datapath — the unix NIO and its c-socket binding survive every leg swap, so frames the container sends while the bridge is stopped for a swap queue on the socket and relay after start. On this datapath suspend rides the synthetic frequency_drop filter (the kernel datapath's suspend is the anchor's admin state).

Frame path for one A → B frame, with the relay diagram's copy numbering: copies 1, 2, 5 and 6 are the socket guest legs and are paid identically on both datapaths — only the middle two swap (TAP fd write/read here, loopback UDP there). The impairment chain fires on the egress of the destination anchor, in order clsact → netem (a classifier drop means netem never sees the frame); each direction is impaired exactly once and B → A is symmetric on gxA's egress chain — delay 100 is therefore collected once per direction, ~200 ms on the RTT (measured ≥ 150 ms):

flowchart LR
    subgraph CTA["IOL A container — userspace"]
        S1["IOL A sends on Eth0/0"]
    end
    subgraph KERN["kernel"]
        SOCKA["c00.sock"]
        SOCKB["s00.sock"]
    end
    subgraph UBA["uBridge port bridge A — userspace"]
        S2["relay"]
    end
    subgraph GXA["gxA — ingress (egress chain serves B→A)"]
        S4["ingress"]
    end
    subgraph BRP["per-link bridge"]
        G1{"reserved 01:80:c2?"}
        D["dropped"]
        S5["FDB lookup / flood"]
    end
    subgraph GXB["gxB — egress chain (clsact, then netem)"]
        S6["eBPF prio 1"]
        S7["bpf_drop prio 10-99"]
        S8["netem qdisc"]
    end
    subgraph UBB["uBridge port bridge B — userspace"]
        S9["relay"]
    end
    subgraph CTB["IOL B container — userspace"]
        S11["IOL B receives on Eth0/0"]
    end
    tap["capture / markers"]
    S1 -->|"copy 1: user→kernel"| SOCKA
    SOCKA -->|"copy 2: kernel→user"| S2
    S2 -->|"copy 3: user→kernel"| S4
    S4 --> G1
    G1 -->|"PAUSE 0x01"| D
    G1 -->|"0xfffd passes"| S5
    S5 --> S6
    S6 --> S7 --> S8
    S8 -->|"copy 4: kernel→user"| S9
    S9 -->|"copy 5: user→kernel"| SOCKB
    SOCKB -->|"copy 6: kernel→user"| S11
    S4 -.->|"AF_PACKET tap"| tap
    S8 -.->|"AF_PACKET tap"| tap

The reserved-range gate passes LACP, LLDP, 802.1X and STP per the port mask but never PAUSE/PFC (Link-local frames below); the clsact/netem stages drop or impair per the installed filters (Capture, markers, filters below). The AF_PACKET taps observe both anchors but never a clsact-dropped frame.

Datapath selection

UDPLink._kernel_datapath_eligible treats an IOL runner container like any Docker node (same compute, Ethernet port) with one gate of its own in _kernel_endpoint_ready: the compute must report both

  • ubridge_tap — the tap module (tap create/tap delete), the same capability QEMU and Dynamips gate on, and
  • ubridge_bridge_tap — bridge delete_nio_tap, the swappable-leg command (probed with one command against a bridge that cannot exist: 202 = old build, 214 = new; no scratch objects, no capabilities, cached by binary identity like every other probe).

Both strict True; anything unreported (old uBridge, failed probe) keeps the link on the relay, where it always works. The generic GNS3_UNIX_SOCKET_NIO containers stay excluded as before — that marker's semantics are image-specific, while GNS3_IOL_RUNNER selects a class whose anchors this server owns.

Anchor lifecycle

  • Birth (node start, before any link) — _start_ubridge runs _prepare_tap_datapath right after the hypervisor connects: probe both capabilities, then for every (bay, unit 0-3) sweep a leftover (tap delete, suppressed), tap create (hardened, starts DOWN) and link set down — carrier off until a link attaches. The anchors exist whether or not any link ever uses them: an anchor born with its link would leave the Ethernet switch fast path's deferred join waiting forever. No tap set_owner: uBridge itself holds these fds.
  • Life — the anchor is never recreated. Link create/delete/switch, suspend, capture and markers only change what is attached to it, and the ensure-then-add contract guards the one window where the device could have been swept (/sys/class/net existence check — tap create is strictly create-only, IFF_TUN_EXCL, and refuses an existing device with EBUSY).
  • Death (node stop) — the port bridges hold the anchor fds (bridge add_nio_tap), so they are deleted first (bridge delete stops their relay threads and frees the NIOs), then the anchors themselves (tap delete answers EBADFD on a device another fd still holds), then the per-link kernel bridges — all while the control channel lives, before the hypervisor stops. A restart recreates everything from the NIO.

Both endpoints attach concurrently and compute the same bridge name independently (a pure function of the link id), so either may win the brctl create race — the loser verifies the bridge exists (brctl show) and continues:

sequenceDiagram
    participant API as REST API
    participant C as Controller
    participant CA as IOL node A
    participant CB as IOL node B
    participant UA as uBridge A
    participant UB as uBridge B

    API->>C: POST /links
    C->>C: eligibility check
    C->>C: bridge = gns3 + link_id[:11]
    C--)CA: nio_bridge
    C--)CB: nio_bridge (same bridge)
    par IOL node A
        CA->>UA: ensure port bridge
        CA->>UA: ensure anchor
        CA->>UA: bridge add_nio_tap
        CA->>UA: brctl create
        CA->>UA: link set bridge up
        CA->>UA: brctl addif gxA
        CA->>UA: port fwd_mask 0xfffd
        CA->>UA: markers / capture if any
        CA->>UA: tc netem + bpf + eBPF
        CA->>UA: bridge start
        CA->>UA: link set gxA up
    and IOL node B
        CB->>UB: same on gxB
    end
    Note over UA,UB: steady state — kernel forwards between the anchors, port bridges relay to the containers

Every arrow to uBridge is one _ubridge_send command line on the node's local hypervisor console; uBridge executes it (netlink/ioctl) and replies OK or an error synchronously before the next command is sent — those replies are omitted for brevity. uBridge never initiates anything: it is a pure executor, and the only data ever flowing back (capture/marker frame copies) rides separate AF_PACKET sockets, not this console.

The attach above is _attach_kernel_link; its ensures are the frozen ensure-then-add contract: the port bridge (bridge create + add_nio_unix) comes from the node's start loop or is created here, and tap create runs only when /sys/class/net shows the anchor missing — it is strictly create-only (IFF_TUN_EXCL) and answers EBUSY on the device the start loop just made. bridge add_nio_tap is what opens and holds the anchor fd, bridge start starts the [unix ↔ tap] relay, and the final link set up refines the anchor's admin state to the NIO's suspend flag — brctl addif has already brought the port up (addif auto-UPs it along with its fwd_mask), so on an unsuspended link the command changes nothing, and a suspended one is what pushes the anchor back down.

  • Link delete (_release_port_tap): bridge stop (delete_nio_tap refuses while running — the relay threads hold the NIO pointers for their whole life; freeing under them is a use-after-free) → bridge delete_nio_tap <bridge> "<tap>" (matched on the kernel-resolved name) → the mixin teardown (markers, tc reset, brctl delif, per-link bridge deletion). The anchor survives — the node owns it, not the link.
  • Relay link — unchanged: the port bridge's second leg is add_nio_udp, userspace filters at the port bridge. The unix NIO stays in its slot across every datapath switch.

Capture, markers, filters

Inherited from KernelDatapathMixin unchanged — everything is keyed on the anchor interface name, which is why a TAP and a veth host end are interchangeable there: capture start_kernel / marker add_kernel (AF_PACKET), one tc netem qdisc (the full netem surface plus the extensions), bpf as cls_bpf match-drop, frequency_drop/quota/window_drop as the eBPF stateful classifier — all capability-gated per compute exactly like the Docker veth datapath (the filter semantics live in packet-filters.md).

Suspend semantics: anchor admin-down. The port bridge's TAP writes fail EIO (tolerated by the delivered uBridge hardening) and its reads fall silent — traffic stops both ways. The runner itself is unaware of the link state (netiomux has no carrier signal), exactly as on the relay datapath where suspend rode the synthetic frequency_drop filter instead.

The bridge stands in for a cable, but the kernel's br_handle_frame() does not forward the IEEE 802.1D reserved range (01:80:c2:00:00:00-0f) by default — LACP, LLDP/DCBX and 802.1X would never cross a kernel link while the UDP relay carries them fine. uBridge therefore opens every port's per-port group_fwd_mask (IFLA_BRPORT_GROUP_FWD_MASK) to 0xfffd at brctl addif time (Linux 4.15+, best-effort on older kernels). The bridge-level knob cannot do this: BR_GROUPFWD_RESTRICTED rejects bits 0-2, so LACP is reachable per port only.

Result on kernel links: ordinary multicast, STP/RSTP, LACP, 802.1X and LLDP/DCBX all cross; 802.3x PAUSE / PFC does not — case 0x01 in br_handle_frame() drops it unconditionally and no mask can enable it. That is a documented limit, not a regression (PFC's hardware semantics are out of emulation's reach anyway); a lab that needs PAUSE frames on the wire must use a relay link. Verified live by tests/e2e/test_docker_link_local_frames.py (per-MAC guest-to-guest matrix); the contract and kernel references live in docs/design/ubridge-link-local-frame-forwarding-spec.md.

Relay fallback

Missing either capability, the node runs relay-only: links ride unix ↔ UDP, kernel_datapath stays false, and anchors are simply never created (or, with enable_kernel_datapath off server-wide, created but never attached — the same semantics as QEMU's and Dynamips' anchors).

Verified

Live on the real server (tests/e2e/test_iol_docker_kernel_datapath.py, isolated instance, real iol-xe/iol-xe:17-18-02 images, real uBridge with bridge delete_nio_tap):

  • kernel link reports kernel_datapath, anchors exist from node start, the per-link bridge enslaves exactly the two of them;
  • real ICMP crosses the [unix ↔ tap] port-bridge relay (100 % ping);
  • the L2-anchor spec on the anchors: FORWARDING ports always, and on a uBridge with link l2only §E.1's pure-L2 checks plus §E.2's silence window with the guests shut — the behavioral half of the hardening on anchors created by uBridge's bridge TAP module (bridge add_nio_tap);
  • delay 100 lands as netem on both anchors, measured RTT grows ≥ 150 ms and returns < 50 ms when cleared;
  • the classifier spot check on a TAP anchor (the Docker suite runs the full matrix on veth host ends), each behind its capability gate: bpf "icmp" drops everything (clsact on the anchor, needs cbpf), frequency_drop 3 as the eBPF every-nth mode (needs the nth token) — round-trip loss asserted 20-90 % around the per-direction theory 1-(2/3)² = 55.6 %, both restoring on clear;
  • suspend admin-downs the anchor and kills the traffic; resume restores;
  • AF_PACKET capture writes a real pcap of the ICMP exchange;
  • link delete/re-create swaps the port bridge's TAP leg out and back in (unix binding never re-binds) with the ping returning;
  • node stop tears the anchors down (no gx devices left), a restart recreates the whole wiring from the NIO;
  • project delete leaves no anchors and no bridges; the relay control on a relay-configured instance keeps the link off the kernel and still pings.

Also caught and fixed live: tap create is strictly create-only (IFF_TUN_EXCL) — the ensure-then-add guard must check existence first (/sys/class/net), not re-create unconditionally (EBUSY on the device the start loop just made).

Configuration

[Server]
enable_kernel_datapath = True   # the global switch (link attachment)

The anchor lifecycle needs no configuration of its own — the capability probes decide per compute, and an IOL runner container without them simply never anchors.

Roadmap

Cross-compute kernel links need VXLAN/GENEVE encapsulation — deferred until every node type is kernelized; with the IOL runner container anchored, every node type in scope can now anchor (hub, cloud, NAT and VPCS stay on the relay by decision), so the encapsulation work is all that remains.