diff --git a/tests/e2e/harness.py b/tests/e2e/harness.py index bff5397c9..a7f821565 100644 --- a/tests/e2e/harness.py +++ b/tests/e2e/harness.py @@ -890,6 +890,25 @@ class DockerDaemon: except (AssertionError, ValueError): return "unknown" + def cp(self, container_id, tar_bytes, path): + """Upload a tar archive into the container at *path* (``docker cp`` + in); the daemon extracts it, honoring the entries' modes.""" + conn = _UnixHTTPConnection(self.socket_path, self.timeout) + try: + conn.request( + "PUT", + f"/containers/{container_id}/archive?path={urllib.parse.quote(path)}", + body=tar_bytes, + headers={"Content-Type": "application/x-tar"}, + ) + response = conn.getresponse() + payload = response.read() + assert response.status == 200, f"docker cp into {container_id[:12]} -> HTTP {response.status}: {payload[:300]!r}" + except (OSError, http.client.HTTPException) as e: + raise AssertionError(f"docker cp into {container_id[:12]} failed: {e}") from None + finally: + conn.close() + def ensure_docker_image(server): """The Docker image the scenarios run, ensured present locally. diff --git a/tests/e2e/test_docker_link_local_frames.py b/tests/e2e/test_docker_link_local_frames.py new file mode 100644 index 000000000..bd4b2a394 --- /dev/null +++ b/tests/e2e/test_docker_link_local_frames.py @@ -0,0 +1,366 @@ +# +# 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; +* 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 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 +CONTROL = ("01005e000001", "0800") # ordinary multicast: 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, 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) + try: + subprocess.run(["cc", "-static", "-O2", "-o", binary, source], check=True, capture_output=True) + except (FileNotFoundError, 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)): + 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)