# # 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 . """ Live end-to-end test for link-local frame transparency on kernel-datapath links (pytest marker ``e2e``): a per-link Linux bridge stands in for a cable, so the IEEE 802.1D reserved range must cross it exactly like the uBridge UDP relay always carried it. uBridge opens every enslave'd port's ``IFLA_BRPORT_GROUP_FWD_MASK`` to ``0xfffd`` (ubridge commit 0284500; the contract lives in ``docs/design/ubridge-link-local-frame-forwarding-spec.md``), which makes STP, LACP, 802.1X and LLDP/DCBX flow between the guests while the kernel keeps hard-dropping MAC PAUSE. ``test_docker_link_local_frames`` — two real Alpine containers on a kernel link; a static raw-Ethernet probe (built at run time, docker-cp'd in — the image has no raw-frame tool) injects frames from guest 1's eth0 while guest 2's eth0 counts what actually arrived, per destination MAC. The scenario asserts, through the server's own wiring path: * the per-link bridge carries both anchors and each anchor's sysfs ``brport/group_fwd_mask`` reads 65533 (0xfffd) — the uBridge default applied by ``brctl addif``, which is the only place gns3-server wires ports; * the bridge-level ``group_fwd_mask`` stays 0 (the fix is per-port only); * the live matrix from the guest: ordinary multicast crosses (wire sanity), STP / LACP / EAPOL / LLDP cross, and PAUSE does not — ``br_handle_frame``'s ``case 0x01`` drops it unconditionally, so PFC stays unsupported by design. The matrix is dual-stack at the frame level: the v4 all-hosts control row has a v6 twin over ethertype 0x86dd (the all-nodes multicast MAC), and the guests deliberately stay v4-addressed — a dual-stack guest's own MLD report would target exactly that MAC and contaminate the per-MAC count window the row is measured with; * a container stop/start replays the NIO wiring and the mask is re-applied (the reconnect story), with a fresh LACP round surviving it. The negative control for the old behaviour is the uBridge suite's ``setportgroupfwd 0`` case (stock kernel semantics), not a second server run. """ import functools import io import os import shutil import subprocess import tarfile import tempfile import time from concurrent.futures import ThreadPoolExecutor import pytest from tests.e2e import harness pytestmark = pytest.mark.e2e # every standard Docker adapter has a single port ETH = (0, 0) R1_IP = "10.2.1.1" R2_IP = "10.2.1.2" # (destination MAC, ethertype) — what the guest protocol would emit. The # v4 control frame's v6 twin is the all-nodes multicast: the same "ordinary # multicast crosses" sanity, over the v6 ethertype. CONTROL = ("01005e000001", "0800") # ordinary multicast: must always cross CONTROL6 = ("333300000001", "86dd") # ordinary v6 multicast (ff02::1): must always cross STP = ("0180c2000000", "0026") # STP BPDU LACP = ("0180c2000002", "8809") # LACP / slow protocols EAPOL = ("0180c2000003", "888e") # 802.1X EAPOL LLDP = ("0180c200000e", "88cc") # LLDP / DCBX PAUSE = ("0180c2000001", "8808") # 802.3x PAUSE — kernel hard-drop MUST_CROSS = [CONTROL, CONTROL6, STP, LACP, EAPOL, LLDP] MUST_NOT_CROSS = [PAUSE] RECV_WINDOW = 12 _LLPROBE_C = r""" /* * llprobe — raw Ethernet send/recv with explicit destination MACs, for the * link-local forwarding e2e. Built static by the test, docker-cp'd into the * containers (the Alpine image has no raw-frame tool of its own). * * llprobe send * llprobe recv ... * * recv prints one "=" line per requested MAC, counting only * inbound frames (PACKET_OUTGOING excluded — a veth host end sees its own * transmissions too). */ #include #include #include #include #include #include #include #include #include #include #include #define MAX_MACS 8 static int parse_mac(const char *hex, unsigned char *out) { char buf[13]; int v[6]; size_t j = 0; for (size_t i = 0; hex[i] && j < 12; i++) if (hex[i] != ':' && hex[i] != '-') buf[j++] = hex[i]; buf[j] = '\0'; if (j != 12 || sscanf(buf, "%2x%2x%2x%2x%2x%2x", &v[0], &v[1], &v[2], &v[3], &v[4], &v[5]) != 6) return -1; for (int i = 0; i < 6; i++) out[i] = (unsigned char)v[i]; return 0; } static int raw_socket(const char *ifname, struct ifreq *hwaddr) { int sck = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); if (sck < 0) { perror("llprobe: socket"); return -1; } if (hwaddr) { memset(hwaddr, 0, sizeof(*hwaddr)); strncpy(hwaddr->ifr_name, ifname, IFNAMSIZ - 1); if (ioctl(sck, SIOCGIFHWADDR, hwaddr) < 0) { perror("llprobe: SIOCGIFHWADDR"); close(sck); return -1; } } struct sockaddr_ll sa; memset(&sa, 0, sizeof(sa)); sa.sll_family = AF_PACKET; sa.sll_protocol = htons(ETH_P_ALL); sa.sll_ifindex = if_nametoindex(ifname); if (sa.sll_ifindex == 0) { fprintf(stderr, "llprobe: no interface %s\n", ifname); close(sck); return -1; } if (bind(sck, (struct sockaddr *)&sa, sizeof(sa)) < 0) { perror("llprobe: bind"); close(sck); return -1; } return sck; } static int do_send(const char *ifname, const char *dst_hex, const char *et_hex, int count) { unsigned char dst[6], frame[64]; unsigned int et = 0; struct ifreq ifr; int sck = raw_socket(ifname, &ifr); if (sck < 0) return 1; if (parse_mac(dst_hex, dst) < 0 || sscanf(et_hex, "%x", &et) != 1) { fprintf(stderr, "llprobe: bad mac %s or ethertype %s\n", dst_hex, et_hex); close(sck); return 1; } for (int n = 0; n < count; n++) { memcpy(frame, dst, 6); memcpy(frame + 6, ifr.ifr_hwaddr.sa_data, 6); frame[12] = (et >> 8) & 0xff; frame[13] = et & 0xff; frame[14] = 'L'; frame[15] = 'L'; frame[16] = (unsigned char)n; memset(frame + 17, 0, sizeof(frame) - 17); if (send(sck, frame, sizeof(frame), 0) < 0) { perror("llprobe: send"); close(sck); return 1; } usleep(20000); } close(sck); printf("sent %d frames to %s on %s\n", count, dst_hex, ifname); return 0; } static int do_recv(const char *ifname, int window, char **macs, int nmacs) { unsigned char want[MAX_MACS][6]; long counts[MAX_MACS] = {0}; unsigned char buf[2048]; int sck = raw_socket(ifname, NULL); if (sck < 0) return 1; for (int i = 0; i < nmacs; i++) if (parse_mac(macs[i], want[i]) < 0) { fprintf(stderr, "llprobe: bad mac %s\n", macs[i]); close(sck); return 1; } struct timeval tv = {0, 500000}; setsockopt(sck, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); long deadline = time(NULL) + window; for (;;) { if (time(NULL) >= deadline) break; struct sockaddr_ll from; socklen_t flen = sizeof(from); ssize_t n = recvfrom(sck, buf, sizeof(buf), 0, (struct sockaddr *)&from, &flen); if (n < 14) continue; /* timeout, error or runt */ if (from.sll_pkttype == PACKET_OUTGOING) continue; for (int i = 0; i < nmacs; i++) if (memcmp(buf, want[i], 6) == 0) counts[i]++; } close(sck); for (int i = 0; i < nmacs; i++) printf("%s=%ld\n", macs[i], counts[i]); return 0; } int main(int argc, char **argv) { if (argc >= 5 && strcmp(argv[1], "send") == 0) return do_send(argv[2], argv[3], argv[4], atoi(argv[5])); if (argc >= 5 && strcmp(argv[1], "recv") == 0) return do_recv(argv[2], atoi(argv[3]), argv + 4, argc - 4); fprintf(stderr, "usage: llprobe send \n" " llprobe recv ...\n"); return 2; } """ @functools.cache def _llprobe(): """Build the static raw-Ethernet probe (cached for the session).""" directory = tempfile.mkdtemp(prefix="gns3-e2e-llprobe-") source = os.path.join(directory, "llprobe.c") binary = os.path.join(directory, "llprobe") with open(source, "w") as f: f.write(_LLPROBE_C) cc = shutil.which("cc") if cc is None: pytest.skip("cannot build the raw-frame probe (no C compiler found)") try: # S603: resolved compiler path, fixed argv, no shell subprocess.run([cc, "-static", "-O2", "-o", binary, source], check=True, capture_output=True) # noqa: S603 except subprocess.CalledProcessError as e: pytest.skip(f"cannot build the raw-frame probe (cc -static): {e}") return binary def _upload_probe(daemon, *container_ids): """docker-cp the probe into every container's /tmp.""" with open(_llprobe(), "rb") as f: payload = f.read() buffer = io.BytesIO() with tarfile.open(fileobj=buffer, mode="w") as tar: info = tarfile.TarInfo("llprobe") info.size = len(payload) info.mode = 0o755 tar.addfile(info, io.BytesIO(payload)) archive = buffer.getvalue() for container_id in container_ids: daemon.cp(container_id, archive, "/tmp") def _recv_counts(daemon, container_id, window, macs): """Run the receiver to completion and parse its "=" lines.""" code, output = daemon.exec(container_id, ["/tmp/llprobe", "recv", "eth0", str(window), *macs], timeout=window + 30) assert code == 0, output return {line.split("=")[0]: int(line.split("=")[1]) for line in output.strip().splitlines() if "=" in line} def _send_all(daemon, container_id, matrix, count=5): for mac, ethertype in matrix: code, output = daemon.exec(container_id, ["/tmp/llprobe", "send", "eth0", mac, ethertype, str(count)]) assert code == 0, output def _port_mask(anchor): """The anchor's link-local forwarding mask, as an int — newer kernels print the sysfs attribute in hex (``0xfffd``), older ones in decimal.""" return int(harness.sysfs(f"/sys/class/net/{anchor}/brport/group_fwd_mask"), 0) def test_docker_link_local_frames(): server = harness.live_server(kernel=True) compute = server.compute image = harness.ensure_docker_image(server) project = compute.create_project("docker-ll") pid = project["project_id"] try: nodes = [] for suffix, address in (("1", R1_IP), ("2", R2_IP)): node = compute.create_docker_node(pid, f"LL-{suffix}", image) harness.configure_docker_interfaces(node, address) nodes.append(node) n1, n2 = nodes daemon = harness.DockerDaemon() for node in nodes: node["container_id"] = daemon.container_id(f"GNS3.{node['name']}.{pid}") assert node["container_id"], f"container for {node['name']} not found" c1, c2 = n1["container_id"], n2["container_id"] link = compute.create_link(pid, (n1["node_id"], *ETH), (n2["node_id"], *ETH)) lid = link["link_id"] assert link["kernel_datapath"] is True, link print(".. starting containers") for node in nodes: compute.call("POST", f"/projects/{pid}/nodes/{node['node_id']}/start") for node, address in zip(nodes, (R1_IP, R2_IP), strict=True): harness.docker_wait_address(daemon, node["container_id"], address) a1 = harness.docker_anchor_name(n1["node_id"], *ETH) a2 = harness.docker_anchor_name(n2["node_id"], *ETH) bridge = harness.link_bridge_name(lid) assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge) # The uBridge default as reached through the server's own wiring: # both anchors open to 0xfffd, the bridge level untouched. for anchor in (a1, a2): mask = _port_mask(anchor) assert mask == 0xFFFD, f"{anchor}: group_fwd_mask {mask:#x} != 0xfffd" assert int(harness.sysfs(f"/sys/class/net/{bridge}/bridge/group_fwd_mask"), 0) == 0 print(".. injecting the link-local matrix from guest 1") _upload_probe(daemon, c1, c2) matrix = MUST_CROSS + MUST_NOT_CROSS with ThreadPoolExecutor(max_workers=1) as pool: receiver = pool.submit(_recv_counts, daemon, c2, RECV_WINDOW, [mac for mac, _ in matrix]) time.sleep(1.5) # let the receiver bind before the first frame _send_all(daemon, c1, matrix) counts = receiver.result() print(f".. guest 2 received: {counts}") for mac, _ in MUST_CROSS: assert counts.get(mac, 0) > 0, f"{mac} did not cross the kernel link: {counts}" for mac, _ in MUST_NOT_CROSS: assert counts.get(mac, 0) == 0, f"{mac} unexpectedly crossed: {counts}" print(".. restarting container 1: the NIO replay must re-apply the mask") compute.call("POST", f"/projects/{pid}/nodes/{n1['node_id']}/stop") harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15) compute.call("POST", f"/projects/{pid}/nodes/{n1['node_id']}/start") harness.docker_wait_address(daemon, c1, R1_IP) assert harness.wait_until(lambda: harness.bridge_members(bridge) == sorted([a1, a2]), timeout=15) mask = _port_mask(a1) assert mask == 0xFFFD, f"{a1} after restart: group_fwd_mask {mask:#x} != 0xfffd" with ThreadPoolExecutor(max_workers=1) as pool: receiver = pool.submit(_recv_counts, daemon, c2, 8, [LACP[0]]) time.sleep(1.5) _send_all(daemon, c1, [LACP]) counts = receiver.result() assert counts.get(LACP[0], 0) > 0, f"LACP dead after the container restart: {counts}" except BaseException: harness.release(server, pid, failed=True) raise harness.release(server, pid)