diff --git a/tests/e2e/README.md b/tests/e2e/README.md index ef5a1ef82..fba5b9671 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -66,6 +66,16 @@ Current scenarios: The Docker scenarios run a digest-pinned `alpine:3`, so every developer tests the same bytes: a cached copy in the local Docker daemon costs no network, a cache miss pulls through the server's own image-pull route, and with neither cache nor registry the scenario skips (`GNS3_E2E_DOCKER_IMAGE` overrides the reference). Their guests are addressed the way a GNS3 user does it — the node's persistent `/etc/network/interfaces`, which the container's `init.sh` applies at boot — and driven through the Docker daemon socket the server itself uses. +## Dual-stack coverage + +Every scenario runs its traffic checkpoints **dual-stack**: the guests carry a ULA IPv6 address next to their IPv4 one (a `fd00:a:b::h` mirror of the v4 `10.a.b.h`), and each checkpoint pings both families — the v6 twin rides the same impairment window as its v4 counterpart rather than a second filter application, so the impairment set is applied once and asserted twice. The serial (HDLC) legs stay v4-only: they are not Ethernet wires. + +The family-sensitive spots get dedicated v6 assertions: the libpcap `bpf` drop filters are asserted to kill their own family and leave the other family alive (`icmp` vs `icmp6`); capture windows carry both families and assert the `86dd` ethertype next to `0800`; the eBPF classifier modes (family-blind by construction) are measured on both families. Marker twins use an echo-specific expression (`icmp6 and (ip6[40] == 128 or ip6[40] == 129)`) because a bare `icmp6` also matches the guests' own DAD/MLD/NS/NA control frames and would break the exact-count asserts — the per-family exact counts (v4 marker sees only v4 frames, v6 marker only v6) double as the cross-family check. + +Docker guests get their v6 address from `harness.docker_bring_up_ipv6` after every start rather than from the interfaces file: on bridge networks without IPv6 the Docker daemon leaves the container interface with `disable_ipv6=1`, so busybox ifup's inet6 stanza would fail with EPERM at boot. The helper switches the interface on, silences DAD transmissions and adds the address; since it does not survive a stop/start, it is re-applied at every container start. + +One platform quirk is recorded here because it looks like a datapath bug and is not one: while a netem delay is applied, the dynamips-emulated classic IOS **under-reports its own `ping ipv6` RTT** by ~40 % of the delayed round trip — a pcap on the anchor shows the v6 echo request→reply taking the same ~230 ms as v4 while the console reports ~130 ms, and with a small single-owned delay the report swallows most of the signal (reproducible; the order of the two pings does not matter; the v4 ping on the same platform reports honestly). The dynamips-family scenarios (dynamips, ethernet switch, cascade) therefore assert their **v6 delay from the capture's own timestamps** (`harness.pcap_icmp_rtt` — the wire truth, immune to any guest's stopwatch), while the absolute v6 ping values are asserted where the guests read the real clock (the Docker scenarios' busybox guests and the native IOL/IOS-XE guests). + Every kernel scenario also asserts the L2-anchor spec's server-side guarantees (`docs/design/ubridge-l2-anchor-spec.md` §E): anchors and bridges carry no L3 identity (`assert_pure_l2`), joined ports are FORWARDING (`assert_forwarding`), and an idle link with the guests silenced stays silent for 5 s (`assert_idle_silence`) — the guests are the IOS consoles' interfaces shut, the Docker guests' links down, or all four cascade routers silenced, whichever the scenario runs. These skip on a uBridge without `link l2only` (probed once by `harness.l2only_supported()`). ## Host environment notes diff --git a/tests/e2e/harness.py b/tests/e2e/harness.py index 217fe2dbc..5dc48b990 100644 --- a/tests/e2e/harness.py +++ b/tests/e2e/harness.py @@ -702,9 +702,14 @@ def ios_console(server, project_id, node_id): return IOSConsole(host, node["console"]) -def configure_ios(console, hostname, eth_ip=None, serial_ip=None, clock=False, eth_if="f1/0", serial_if="s2/0"): +def configure_ios( + console, hostname, eth_ip=None, eth_ipv6=None, serial_ip=None, clock=False, eth_if="f1/0", serial_if="s2/0" +): """Baseline config on a freshly booted router: hostname, no console spam, - optional interface addresses.""" + optional interface addresses. ``eth_ipv6`` configures the same interface + dual-stack (a /64 prefix); ipv6 unicast-routing is deliberately left off — + the routers stay pure hosts, so no RAs go out and the link stays quiet + (directly connected pings need no forwarding).""" console.run("enable") console.run("conf t") console.run(f"hostname {hostname}") @@ -712,9 +717,12 @@ def configure_ios(console, hostname, eth_ip=None, serial_ip=None, clock=False, e # global form: console logging off in one step, no line sub-mode to lose # characters in console.run("no logging console") - if eth_ip: + if eth_ip or eth_ipv6: console.run(f"interface {eth_if}") - console.run(f"ip address {eth_ip} 255.255.255.0") + if eth_ip: + console.run(f"ip address {eth_ip} 255.255.255.0") + if eth_ipv6: + console.run(f"ipv6 address {eth_ipv6}/64") console.run("no shutdown") console.run("exit") if serial_ip: @@ -737,8 +745,12 @@ FAST_RTT_MS = 100 def ping(console, target, repeat=3, timeout=1): - """Run an IOS ping; returns {success, avg, raw} (success in percent).""" - out = console.run(f"ping {target} repeat {repeat} timeout {timeout}", timeout=repeat * timeout + 20) + """Run an IOS ping; returns {success, avg, raw} (success in percent). + + An IPv6 literal needs IOS's ``ping ipv6`` form; the summary lines the + parsers read are the same shape for both families.""" + family = "ipv6 " if ":" in target else "" + out = console.run(f"ping {family}{target} repeat {repeat} timeout {timeout}", timeout=repeat * timeout + 20) success = re.search(r"Success rate is (\d+) percent", out) rtt = re.search(r"round-trip min/avg/max = (\d+)/(\d+)/(\d+)", out) return {"success": int(success.group(1)) if success else 0, "avg": int(rtt.group(2)) if rtt else None, "raw": out} @@ -968,7 +980,10 @@ def configure_docker_interfaces(node, address, netmask="255.255.255.0", adapter= user does: write the persistent ``/etc/network/interfaces`` the server bind-mounts into the container and init.sh applies with busybox ifup at boot (CIDR is not understood — separate address/netmask lines). Call - before starting the node.""" + before starting the node. + + IPv4 only: an inet6 stanza would never apply — see + ``docker_bring_up_ipv6``.""" directory = node.get("node_directory") if not directory or not os.path.isdir(directory): pytest.skip(f"cannot reach the node directory {directory!r} — this scenario needs the compute on this host") @@ -978,12 +993,45 @@ def configure_docker_interfaces(node, address, netmask="255.255.255.0", adapter= f.write(f"auto eth{adapter}\niface eth{adapter} inet static\n\taddress {address}\n\tnetmask {netmask}\n") -def docker_wait_address(daemon, container_id, address, timeout=30): - """Wait until the container's eth0 carries *address* — init.sh applies - the interfaces file (busybox ifup) a moment after container start.""" +def docker_bring_up_ipv6(daemon, container_id, address6, adapter=0): + """Give the container's eth{adapter} its global IPv6 address. Call + after every container start (the address does not survive a stop: the + netns is rebuilt). + + IPv6 cannot ride the interfaces file here: on bridge networks without + IPv6 the Docker daemon leaves the container interface with + ``disable_ipv6=1``, which makes busybox ifup's ``ip -6 addr add`` fail + with EPERM before init.sh's ``ifup -a`` ever finishes the stanza — the + interface must be switched on by hand first. DAD transmissions are + silenced before the address is added: the kernel still holds the + address tentative for ~1 s (measured; ~2 s with the default one DAD + solicitation), but nothing here tests DAD and the solicitations are one + less source of boot-time chatter on the wire.""" + code, output = daemon.exec( + container_id, + [ + "sh", + "-c", + f"echo 0 > /proc/sys/net/ipv6/conf/eth{adapter}/dad_transmits" + f" && echo 0 > /proc/sys/net/ipv6/conf/eth{adapter}/disable_ipv6" + f" && ip -6 addr add {address6}/64 dev eth{adapter}", + ], + ) + assert code == 0, output + + +def docker_wait_address(daemon, container_id, address, timeout=30, adapter=0): + """Wait until the container's eth{adapter} carries *address* (either + family) — init.sh applies the interfaces file (busybox ifup) a moment + after container start. A v6 address also has to leave the tentative + state (a tentative address answers no neighbour solicitation, so pings + to it cannot resolve).""" + family = "-6" if ":" in address else "-4" def present(): - _code, output = daemon.exec(container_id, ["ip", "-4", "-o", "addr", "show", "dev", "eth0"]) + _code, output = daemon.exec(container_id, ["ip", family, "-o", "addr", "show", "dev", f"eth{adapter}"]) + if family == "-6": + return address.lower() in output.lower() and "tentative" not in output.lower() return address in output if not wait_until(present, timeout=timeout, interval=0.5): @@ -1319,11 +1367,14 @@ def wait_until(predicate, timeout=10, interval=0.3): def pcap_records(path): - """Count the records of a pcap file and collect the ethertype of the - first few (hex, e.g. '0800') — enough to prove a capture is real.""" + """Count the records of a pcap file and collect the distinct ethertypes + across all of them (hex, e.g. '0800') — enough to prove a capture is + real. Scans every record, not a leading sample: a dual-stack host's + own IPv6 control frames (DAD/MLD) can precede the traffic under test, + and a truncated sample would let them mask it.""" import struct - ethertypes = [] + ethertypes = set() with open(path, "rb") as f: header = f.read(24) if len(header) < 24: @@ -1339,6 +1390,49 @@ def pcap_records(path): if len(data) < caplen: break count += 1 - if len(ethertypes) < 5 and caplen >= 14: - ethertypes.append(data[12:14].hex()) + if caplen >= 14: + ethertypes.add(data[12:14].hex()) return count, ethertypes + + +def pcap_icmp_rtt(path, ethertype): + """The mean echo request->reply RTT (ms) in a capture, from the + capture's own timestamps — the wire truth, independent of whatever a + guest's ping reports (the dynamips-emulated classic IOS under-reports + its ``ping ipv6`` RTT by roughly 40 % of a delayed round trip; this + primitive is what the dynamips-family scenarios assert instead). + + Pairs each echo request with the next echo reply (``ethertype`` is + '0800' or '86dd'; ICMP echo type 8/0 for v4, 128/129 for v6 with the + fixed 40-byte IPv6 header — no extension headers in these pings). + Returns None when the capture holds no complete exchange.""" + import struct + + rows = [] + with open(path, "rb") as f: + header = f.read(24) + if len(header) < 24: + return None + endian = "<" if header[:4] in (b"\xd4\xc3\xb2\xa1", b"\x4d\x3c\xb2\xa1") else ">" + while True: + rec = f.read(16) + if len(rec) < 16: + break + ts_s, ts_us, caplen, _orig = struct.unpack(endian + "IIII", rec) + data = f.read(caplen) + if len(data) < caplen: + break + if caplen < 14 or data[12:14].hex() != ethertype: + continue + icmp_off = 14 + ((data[14] & 0xF) * 4 if ethertype == "0800" else 40) + if caplen >= icmp_off + 1: + rows.append((ts_s + ts_us / 1e6, data[icmp_off])) + request_type = 8 if ethertype == "0800" else 128 + deltas, pending = [], None + for ts, icmp_type in rows: + if icmp_type == request_type: + pending = ts + elif icmp_type == request_type + 1 and pending is not None: + deltas.append((ts - pending) * 1000) + pending = None + return sum(deltas) / len(deltas) if deltas else None diff --git a/tests/e2e/test_docker_kernel_datapath.py b/tests/e2e/test_docker_kernel_datapath.py index 04c7d5e50..f3473ef55 100644 --- a/tests/e2e/test_docker_kernel_datapath.py +++ b/tests/e2e/test_docker_kernel_datapath.py @@ -49,6 +49,13 @@ server itself uses. The scenarios cover: The image is pinned by digest (``harness.ensure_docker_image``): a cached copy costs no network, a miss pulls through the server's own pull route, and no cache + no registry skips the scenario. + +Every traffic assertion runs dual-stack: the guests carry a ULA IPv6 +address next to their IPv4 one and each checkpoint pings both families — +including the filter matrix (netem and the classifiers are family-blind; +the libpcap bpf drops are deliberately not) and the relay markers (a v6 +twin marker with an echo-specific expression, since a bare ``icmp6`` +would also match the guests' own DAD/MLD control frames). """ import os @@ -65,6 +72,16 @@ pytestmark = pytest.mark.e2e ETH = (0, 0) R1_IP = "10.1.1.1" R2_IP = "10.1.1.2" +# the dual-stack half: a ULA mirroring the v4 addressing (10.1.1.x -> fd00:1:1::x) +R1_IP6 = "fd00:1:1::1" +R2_IP6 = "fd00:1:1::2" + +# exact ICMPv6 echo match for markers: a bare "icmp6" would also match the +# guests' own DAD/MLD/NS/NA control frames and break the exact-count asserts +_ICMP6_ECHO_BPF = "icmp6 and (ip6[40] == 128 or ip6[40] == 129)" +# busybox ping frame lengths (56 data bytes): 14 Ethernet + 20/40 IP + 8 ICMP + 56 +PING_FRAME_LEN = 98 +PING_FRAME_LEN6 = 118 def _resolve_container(daemon, node): @@ -78,8 +95,9 @@ def _resolve_container(daemon, node): def _create_topology(server, pid, image, name): - """Two containers with addressed eth0s. The harness reaches their guests - through the Docker daemon socket — the same socket the server uses.""" + """Two containers with dual-stack addressed eth0s. The harness reaches + their guests through the Docker daemon socket — the same socket the + server uses.""" compute = server.compute daemon = harness.DockerDaemon() nodes = [] @@ -91,12 +109,17 @@ def _create_topology(server, pid, image, name): def _start_addresses(compute, pid, daemon, nodes, addresses): - """Start the containers and wait for init.sh to have applied their - addresses (busybox ifup runs a moment after container start).""" + """Start the containers and wait for init.sh to have applied their v4 + addresses (busybox ifup runs a moment after container start); the v6 + addresses are brought up by the harness after each start (see + ``harness.docker_bring_up_ipv6`` — an interface-side disable_ipv6 the + Docker daemon sets makes the interfaces-file route impossible).""" for node in nodes: compute.call("POST", f"/projects/{pid}/nodes/{node['node_id']}/start") - for node, address in zip(nodes, addresses, strict=False): + for node, (address, address6) in zip(nodes, addresses, strict=False): harness.docker_wait_address(daemon, node["container_id"], address) + harness.docker_bring_up_ipv6(daemon, node["container_id"], address6) + harness.docker_wait_address(daemon, node["container_id"], address6) def _put_filters(compute, pid, lid, filters): @@ -134,9 +157,14 @@ def _silence_guest(daemon, container_id): assert code == 0, output -def _unsilence_guest(daemon, container_id): +def _unsilence_guest(daemon, container_id, address6): + """Reverse of ``_silence_guest``. disable_ipv6=1 removed the v6 + addresses and busybox ifup does not re-run, so the guest's global + address must be brought up again by hand (the v4 address stayed + configured throughout).""" code, output = daemon.exec(container_id, ["ip", "link", "set", "eth0", "up"]) assert code == 0, output + harness.docker_bring_up_ipv6(daemon, container_id, address6) def test_docker_kernel_fast_path(): @@ -164,7 +192,7 @@ def test_docker_kernel_fast_path(): assert not harness.tap_exists(a1), "the veth pair is born at container start" print(".. starting containers") - _start_addresses(compute, pid, daemon, (n1, n2), (R1_IP, R2_IP)) + _start_addresses(compute, pid, daemon, (n1, n2), ((R1_IP, R1_IP6), (R2_IP, R2_IP6))) # Kernel objects: the veth host ends exist and the per-link bridge # enslaves exactly the two of them. @@ -187,6 +215,9 @@ def test_docker_kernel_fast_path(): baseline = harness.docker_wait_ping(daemon, c1, R2_IP) assert baseline["loss"] == 0, baseline["raw"] assert baseline["avg"] is not None and baseline["avg"] < 50, baseline + baseline6 = harness.docker_wait_ping(daemon, c1, R2_IP6) + assert baseline6["loss"] == 0, baseline6["raw"] + assert baseline6["avg"] is not None and baseline6["avg"] < 50, baseline6 # §E.2: with the guests silenced the segment is silent for 5 s — # the host anchors do not generate the MLD/DAD flood an unhardened @@ -195,12 +226,14 @@ def test_docker_kernel_fast_path(): _silence_guest(daemon, c1) _silence_guest(daemon, c2) harness.assert_idle_silence(a1, a2, bridge) - _unsilence_guest(daemon, c1) - _unsilence_guest(daemon, c2) + _unsilence_guest(daemon, c1, R1_IP6) + _unsilence_guest(daemon, c2, R2_IP6) assert harness.docker_wait_ping(daemon, c1, R2_IP, attempts=5)["loss"] == 0 + assert harness.docker_wait_ping(daemon, c1, R2_IP6, attempts=5)["loss"] == 0 # delay 100: netem on both host ends (each impairs one direction), - # one-way ~100 ms => RTT grows by ~200 ms + # one-way ~100 ms => RTT grows by ~200 ms — netem is family-blind, + # both families ride the same qdisc _put_filters(compute, pid, lid, {"delay": [100]}) assert "netem" in harness.qdiscs(a1) and "netem" in harness.qdiscs(a2), ( harness.qdiscs(a1), @@ -208,10 +241,14 @@ def test_docker_kernel_fast_path(): ) delayed = harness.docker_wait_ping(daemon, c1, R2_IP) assert delayed["loss"] == 0 and delayed["avg"] >= 150, (baseline, delayed) + delayed6 = harness.docker_wait_ping(daemon, c1, R2_IP6) + assert delayed6["loss"] == 0 and delayed6["avg"] >= 150, (baseline6, delayed6) _put_filters(compute, pid, lid, {}) assert "netem" not in harness.qdiscs(a1), harness.qdiscs(a1) fast = harness.docker_wait_ping(daemon, c1, R2_IP) assert fast["loss"] == 0 and fast["avg"] < 50, fast + fast6 = harness.docker_wait_ping(daemon, c1, R2_IP6) + assert fast6["loss"] == 0 and fast6["avg"] < 50, fast6 # suspend: the host end admin-downs (carrier loss into the # container), the link is dead; resume restores @@ -219,19 +256,25 @@ def test_docker_kernel_fast_path(): assert not harness.tap_up(a1) dead = harness.docker_ping(daemon, c1, R2_IP, count=3, timeout=1) assert dead["loss"] == 100, dead["raw"] + dead6 = harness.docker_ping(daemon, c1, R2_IP6, count=3, timeout=1) + assert dead6["loss"] == 100, dead6["raw"] compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": False}) assert harness.tap_up(a1) assert harness.docker_wait_ping(daemon, c1, R2_IP)["loss"] == 0 + assert harness.docker_wait_ping(daemon, c1, R2_IP6)["loss"] == 0 - # capture: AF_PACKET on the anchor writes a real pcap + # capture: AF_PACKET on the anchor writes a real pcap — both + # families inside the window, both ethertypes on the wire capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"}) harness.docker_ping(daemon, c1, R2_IP) + harness.docker_ping(daemon, c1, R2_IP6) time.sleep(1) compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop") path = capture["capture_file_path"] count, ethertypes = harness.pcap_records(path) - assert count >= 4, f"{path}: {count} records" + assert count >= 8, f"{path}: {count} records" assert "0800" in ethertypes, (path, ethertypes) + assert "86dd" in ethertypes, (path, ethertypes) # link delete detaches both ends (the veths survive — they are the # adapters); re-creating enslaves them again and the traffic returns @@ -243,6 +286,7 @@ def test_docker_kernel_fast_path(): bridge = harness.link_bridge_name(lid) assert link["kernel_datapath"] is True, link assert harness.docker_wait_ping(daemon, c1, R2_IP, attempts=5)["loss"] == 0 + assert harness.docker_wait_ping(daemon, c1, R2_IP6, attempts=5)["loss"] == 0 # container stop: the veth host ends are deleted explicitly (a veth # outlives its container's netns) and the bridge goes with them; a @@ -253,9 +297,12 @@ def test_docker_kernel_fast_path(): assert harness.bridge_members(bridge) in (None, [a2]), harness.bridge_members(bridge) compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start") harness.docker_wait_address(daemon, c1, R1_IP) + harness.docker_bring_up_ipv6(daemon, c1, R1_IP6) + harness.docker_wait_address(daemon, c1, R1_IP6) assert harness.tap_exists(a1) assert harness.wait_until(lambda: harness.bridge_members(bridge) == sorted([a1, a2]), timeout=15) assert harness.docker_wait_ping(daemon, c1, R2_IP, attempts=5)["loss"] == 0 + assert harness.docker_wait_ping(daemon, c1, R2_IP6, attempts=5)["loss"] == 0 except BaseException: harness.release(server, pid, failed=True) raise @@ -293,9 +340,11 @@ def test_docker_kernel_filter_matrix(): lid = link["link_id"] assert link["kernel_datapath"] is True, link - _start_addresses(compute, pid, daemon, (n1, n2), (R1_IP, R2_IP)) + _start_addresses(compute, pid, daemon, (n1, n2), ((R1_IP, R1_IP6), (R2_IP, R2_IP6))) baseline = harness.docker_wait_ping(daemon, c1, R2_IP) assert baseline["loss"] == 0, baseline["raw"] + baseline6 = harness.docker_wait_ping(daemon, c1, R2_IP6) + assert baseline6["loss"] == 0, baseline6["raw"] # The kernel link offers the kernel-only types the compute's uBridge # reports it can run (all of them on this uBridge). @@ -314,6 +363,9 @@ def test_docker_kernel_filter_matrix(): delayed = harness.docker_ping(daemon, c1, R2_IP) print(f".. {delayed['avg']} ms RTT") assert delayed["loss"] == 0 and delayed["avg"] >= 150, (baseline, delayed) + delayed6 = harness.docker_ping(daemon, c1, R2_IP6) + print(f".. {delayed6['avg']} ms RTT (v6)") + assert delayed6["loss"] == 0 and delayed6["avg"] >= 150, (baseline6, delayed6) print(".. delay 50 jitter 20 distribution normal") _put_filters(compute, pid, lid, {"delay": [50, 20, "normal"]}) @@ -325,6 +377,9 @@ def test_docker_kernel_filter_matrix(): jittered = harness.docker_ping(daemon, c1, R2_IP, count=10) print(f".. {jittered['avg']} ms RTT") assert jittered["loss"] == 0 and jittered["avg"] >= 65, jittered + jittered6 = harness.docker_ping(daemon, c1, R2_IP6, count=10) + print(f".. {jittered6['avg']} ms RTT (v6)") + assert jittered6["loss"] == 0 and jittered6["avg"] >= 65, jittered6 _clear_filters(compute, pid, lid, a1, a2) print(".. packet_loss 30") @@ -334,6 +389,9 @@ def test_docker_kernel_filter_matrix(): # round trip = 1 - 0.7^2 ~ 51 %; the stochastic draw must land # somewhere between "clearly lossy" and "not a black hole" assert 20 <= lossy["loss"] <= 80, lossy["raw"] + lossy6 = harness.docker_ping(daemon, c1, R2_IP6, count=20) + print(f".. {lossy6['loss']} % round-trip loss (v6)") + assert 20 <= lossy6["loss"] <= 80, lossy6["raw"] _clear_filters(compute, pid, lid, a1, a2) print(".. corrupt 30") @@ -344,6 +402,9 @@ def test_docker_kernel_filter_matrix(): corrupt = harness.docker_ping(daemon, c1, R2_IP, count=20) print(f".. {corrupt['loss']} % round-trip loss") assert 20 <= corrupt["loss"] <= 80, corrupt["raw"] + corrupt6 = harness.docker_ping(daemon, c1, R2_IP6, count=20) + print(f".. {corrupt6['loss']} % round-trip loss (v6)") + assert 20 <= corrupt6["loss"] <= 80, corrupt6["raw"] _clear_filters(compute, pid, lid, a1, a2) print(".. duplicate 50") @@ -352,6 +413,8 @@ def test_docker_kernel_filter_matrix(): # duplicated frames are absorbed by the stack: no loss, traffic flows dups = harness.docker_ping(daemon, c1, R2_IP, count=5) assert dups["loss"] == 0, dups["raw"] + dups6 = harness.docker_ping(daemon, c1, R2_IP6, count=5) + assert dups6["loss"] == 0, dups6["raw"] _clear_filters(compute, pid, lid, a1, a2) # -- netem extensions (kernel-only) -------------------------------- @@ -360,6 +423,8 @@ def test_docker_kernel_filter_matrix(): assert "reorder" in harness.qdiscs(a1), harness.qdiscs(a1) reordered = harness.docker_ping(daemon, c1, R2_IP, count=10) assert reordered["loss"] <= 60, reordered["raw"] + reordered6 = harness.docker_ping(daemon, c1, R2_IP6, count=10) + assert reordered6["loss"] <= 60, reordered6["raw"] _clear_filters(compute, pid, lid, a1, a2) print(".. delay 50 + seed 42 + limit 5000") @@ -367,6 +432,8 @@ def test_docker_kernel_filter_matrix(): assert "limit 5000" in harness.qdiscs(a1), harness.qdiscs(a1) seeded = harness.docker_ping(daemon, c1, R2_IP) assert seeded["loss"] == 0 and seeded["avg"] >= 80, seeded + seeded6 = harness.docker_ping(daemon, c1, R2_IP6) + assert seeded6["loss"] == 0 and seeded6["avg"] >= 80, seeded6 _clear_filters(compute, pid, lid, a1, a2) print(".. gemodel 100/0/30 (steady-state bad, 30 % loss)") @@ -375,6 +442,9 @@ def test_docker_kernel_filter_matrix(): gemodel = harness.docker_ping(daemon, c1, R2_IP, count=20) print(f".. {gemodel['loss']} % round-trip loss") assert 20 <= gemodel["loss"] <= 90, gemodel["raw"] + gemodel6 = harness.docker_ping(daemon, c1, R2_IP6, count=20) + print(f".. {gemodel6['loss']} % round-trip loss (v6)") + assert 20 <= gemodel6["loss"] <= 90, gemodel6["raw"] _clear_filters(compute, pid, lid, a1, a2) print(".. rate 512kbit (1400-byte pings)") @@ -384,15 +454,28 @@ def test_docker_kernel_filter_matrix(): rated = harness.docker_ping(daemon, c1, R2_IP, size=1400) print(f".. {rated['avg']} ms RTT") assert rated["loss"] == 0 and rated["avg"] >= 30, (baseline, rated) + rated6 = harness.docker_ping(daemon, c1, R2_IP6, size=1400) + print(f".. {rated6['avg']} ms RTT (v6)") + assert rated6["loss"] == 0 and rated6["avg"] >= 30, (baseline6, rated6) _clear_filters(compute, pid, lid, a1, a2) # -- cls_bpf match-drop -------------------------------------------- if caps.get("cbpf"): - print(".. bpf 'icmp' drops everything") + print(".. bpf 'icmp' drops v4 ICMP only") _put_filters(compute, pid, lid, {"bpf": ["icmp"]}) assert "clsact" in harness.qdiscs(a1), harness.qdiscs(a1) dropped = harness.docker_ping(daemon, c1, R2_IP, count=4) assert dropped["loss"] == 100, dropped["raw"] + # the libpcap expression is family-specific: v6 ICMP sails through + v6_alive = harness.docker_ping(daemon, c1, R2_IP6, count=4) + assert v6_alive["loss"] == 0, v6_alive["raw"] + + print(".. bpf 'icmp6' drops v6 ICMP only") + _put_filters(compute, pid, lid, {"bpf": ["icmp6"]}) + v4_alive = harness.docker_ping(daemon, c1, R2_IP, count=4) + assert v4_alive["loss"] == 0, v4_alive["raw"] + dropped6 = harness.docker_ping(daemon, c1, R2_IP6, count=4) + assert dropped6["loss"] == 100, dropped6["raw"] print(".. bpf 'greater 150' discriminates by frame size") _put_filters(compute, pid, lid, {"bpf": ["greater 150"]}) @@ -400,6 +483,12 @@ def test_docker_kernel_filter_matrix(): assert small["loss"] == 0, small["raw"] big = harness.docker_ping(daemon, c1, R2_IP, count=4, size=300) assert big["loss"] == 100, big["raw"] + # the v6 echo frame is 118 bytes — still under the threshold; + # size=300 pushes it to 362, over it + small6 = harness.docker_ping(daemon, c1, R2_IP6, count=4) + assert small6["loss"] == 0, small6["raw"] + big6 = harness.docker_ping(daemon, c1, R2_IP6, count=4, size=300) + assert big6["loss"] == 100, big6["raw"] _clear_filters(compute, pid, lid, a1, a2) else: print(".. uBridge reports no cbpf: skipping the bpf filter checks") @@ -413,9 +502,16 @@ def test_docker_kernel_filter_matrix(): print(f".. {nth['loss']} % round-trip loss") # kernel counts per direction: 1 - (2/3)^2 = 55.6 % round trip assert 30 <= nth["loss"] <= 80, nth["raw"] + # the classifier draws per packet, not per header — v6 rides + # the same distribution + nth6 = harness.docker_ping(daemon, c1, R2_IP6, count=20) + print(f".. {nth6['loss']} % round-trip loss (v6)") + assert 30 <= nth6["loss"] <= 80, nth6["raw"] _put_filters(compute, pid, lid, {"frequency_drop": [-1]}) all_dropped = harness.docker_ping(daemon, c1, R2_IP, count=4) assert all_dropped["loss"] == 100, all_dropped["raw"] + all_dropped6 = harness.docker_ping(daemon, c1, R2_IP6, count=4) + assert all_dropped6["loss"] == 100, all_dropped6["raw"] _clear_filters(compute, pid, lid, a1, a2) if "quota" in modes: print(".. quota 3000 bytes hard cutoff") @@ -426,8 +522,13 @@ def test_docker_kernel_filter_matrix(): assert cut["loss"] >= 40, cut["raw"] after = harness.docker_ping(daemon, c1, R2_IP, count=4) assert after["loss"] == 100, after["raw"] + cut6 = harness.docker_ping(daemon, c1, R2_IP6, count=5, size=1400) + assert cut6["loss"] >= 40, cut6["raw"] + after6 = harness.docker_ping(daemon, c1, R2_IP6, count=4) + assert after6["loss"] == 100, after6["raw"] _clear_filters(compute, pid, lid, a1, a2) assert harness.docker_ping(daemon, c1, R2_IP, count=4)["loss"] == 0 + assert harness.docker_ping(daemon, c1, R2_IP6, count=4)["loss"] == 0 if "window" in modes: print(".. window_drop [1000, 2000, 100] — one outage with passes on both sides") _put_filters(compute, pid, lid, {"window_drop": [1000, 2000, 100]}) @@ -438,8 +539,19 @@ def test_docker_kernel_filter_matrix(): missing = set(range(25)) - replied assert 0 in replied and 24 in replied, (sorted(replied), output) assert missing and max(missing) - min(missing) >= 5, (sorted(replied), output) + # the outage window is one-shot from the moment the filter is + # applied (documented semantics), so the v6 stream needs the + # schedule re-armed — re-sending the same window is a no-op + # except that it restarts the schedule + _put_filters(compute, pid, lid, {"window_drop": [1000, 2000, 100]}) + code, output = daemon.exec(c1, ["ping", "-c", "25", "-i", "0.2", "-W", "1", R2_IP6], timeout=30) + replied6 = {int(m.group(1)) for m in re.finditer(r"seq=(\d+)", output)} + missing6 = set(range(25)) - replied6 + assert 0 in replied6 and 24 in replied6, (sorted(replied6), output) + assert missing6 and max(missing6) - min(missing6) >= 5, (sorted(replied6), output) _clear_filters(compute, pid, lid, a1, a2) assert harness.docker_ping(daemon, c1, R2_IP, count=4)["loss"] == 0 + assert harness.docker_ping(daemon, c1, R2_IP6, count=4)["loss"] == 0 except BaseException: harness.release(server, pid, failed=True) raise @@ -471,7 +583,7 @@ def test_docker_relay_control(): link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH)) assert link["kernel_datapath"] is False, link - _start_addresses(compute, pid, daemon, (n1, n2), (R1_IP, R2_IP)) + _start_addresses(compute, pid, daemon, (n1, n2), ((R1_IP, R1_IP6), (R2_IP, R2_IP6))) # No kernel wiring: no per-link bridge, the veth host end is just a # relay endpoint (up, pure L2 — the hardening is a creation-time @@ -484,6 +596,8 @@ def test_docker_relay_control(): relay_ping = harness.docker_wait_ping(daemon, n1["container_id"], R2_IP) assert relay_ping["loss"] == 0, relay_ping["raw"] + relay_ping6 = harness.docker_wait_ping(daemon, n1["container_id"], R2_IP6) + assert relay_ping6["loss"] == 0, relay_ping6["raw"] # available_filters hides the kernel-only types on the relay offered = _filter_types(compute, pid, link["link_id"]) @@ -502,9 +616,14 @@ def test_docker_relay_control(): delayed = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=5) assert delayed["loss"] == 0, delayed["raw"] assert delayed["avg"] >= 150, (relay_ping, delayed) + delayed6 = harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=5) + assert delayed6["loss"] == 0, delayed6["raw"] + assert delayed6["avg"] >= 150, (relay_ping6, delayed6) _put_filters(compute, pid, link["link_id"], {}) fast = harness.docker_wait_ping(daemon, n1["container_id"], R2_IP) assert fast["loss"] == 0 and fast["avg"] < 50, (relay_ping, fast) + fast6 = harness.docker_wait_ping(daemon, n1["container_id"], R2_IP6) + assert fast6["loss"] == 0 and fast6["avg"] < 50, (relay_ping6, fast6) # frequency_drop counts every packet crossing the bridge, both # directions sharing one counter, so on an alternating ping stream @@ -516,24 +635,43 @@ def test_docker_relay_control(): dropped = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=10) print(f".. {dropped['loss']} % round-trip loss") assert dropped["loss"] >= 50, dropped["raw"] + dropped6 = harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=10) + print(f".. {dropped6['loss']} % round-trip loss (v6)") + assert dropped6["loss"] >= 50, dropped6["raw"] _put_filters(compute, pid, link["link_id"], {"frequency_drop": [-1]}) blackholed = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=4) assert blackholed["loss"] == 100, blackholed["raw"] + blackholed6 = harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=4) + assert blackholed6["loss"] == 100, blackholed6["raw"] # bpf on the relay is uBridge's libpcap userspace match-drop — a - # different engine from the kernel datapath's cls_bpf - print(".. relay filters: bpf 'icmp' drops every ICMP frame") + # different engine from the kernel datapath's cls_bpf, same + # family-specific expression semantics + print(".. relay filters: bpf 'icmp' drops v4 ICMP only") _put_filters(compute, pid, link["link_id"], {"bpf": ["icmp"]}) icmp_dropped = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=4) assert icmp_dropped["loss"] == 100, icmp_dropped["raw"] + v6_alive = harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=4) + assert v6_alive["loss"] == 0, v6_alive["raw"] + print(".. relay filters: bpf 'icmp6' drops v6 ICMP only") + _put_filters(compute, pid, link["link_id"], {"bpf": ["icmp6"]}) + v4_alive = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=4) + assert v4_alive["loss"] == 0, v4_alive["raw"] + icmp6_dropped = harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=4) + assert icmp6_dropped["loss"] == 100, icmp6_dropped["raw"] print(".. relay filters: bpf 'greater 150' discriminates by frame size") _put_filters(compute, pid, link["link_id"], {"bpf": ["greater 150"]}) small = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=4) assert small["loss"] == 0, small["raw"] big = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=4, size=300) assert big["loss"] == 100, big["raw"] + small6 = harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=4) + assert small6["loss"] == 0, small6["raw"] + big6 = harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=4, size=300) + assert big6["loss"] == 100, big6["raw"] _put_filters(compute, pid, link["link_id"], {}) assert harness.docker_ping(daemon, n1["container_id"], R2_IP, count=5)["loss"] == 0 + assert harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=5)["loss"] == 0 # capture on the relay: the same REST call as the kernel datapath's, # hosted by the relay node's uBridge bridge (bridge start_capture) @@ -543,18 +681,20 @@ def test_docker_relay_control(): "POST", f"/projects/{pid}/links/{link['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"} ) harness.docker_ping(daemon, n1["container_id"], R2_IP) + harness.docker_ping(daemon, n1["container_id"], R2_IP6) time.sleep(1) compute.call("POST", f"/projects/{pid}/links/{link['link_id']}/capture/stop") path = capture["capture_file_path"] count, ethertypes = harness.pcap_records(path) - assert count >= 4, f"{path}: {count} records" + assert count >= 8, f"{path}: {count} records" assert "0800" in ethertypes, (path, ethertypes) + assert "86dd" in ethertypes, (path, ethertypes) # markers on the relay are uBridge's `mark` packet filter on the # relay bridge (bridge add_packet_filter ... mark), signalled over # the same dedicated marker WS — the relay engine's observability # path (the kernel datapath's marker add_kernel is the other one) - print(".. relay marker: 5 ICMP echoes through a mark filter on the relay bridge") + print(".. relay markers: 5 v4 + 5 v6 ICMP echoes through mark filters on the relay bridge") marker_ws = harness.WebSocketCollector(server, f"/projects/{pid}/notifications/markers/ws") try: m = compute.call( @@ -563,25 +703,54 @@ def test_docker_relay_control(): {"name": "m-icmp", "bpf": "icmp", "tag": 4242, "capture_node_id": n1_id}, ) assert m["capture_node_id"] == n1_id and m["enabled"] is True, m + m6 = compute.call( + "POST", + f"/projects/{pid}/links/{link['link_id']}/markers", + {"name": "m-icmp6", "bpf": _ICMP6_ECHO_BPF, "tag": 4243, "capture_node_id": n1_id}, + ) + assert m6["capture_node_id"] == n1_id and m6["enabled"] is True, m6 result = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=5) assert result["loss"] == 0, result["raw"] - harness.wait_until(lambda: len(_matches(marker_ws, "m-icmp")) >= 10, timeout=10) + result6 = harness.docker_ping(daemon, n1["container_id"], R2_IP6, count=5) + assert result6["loss"] == 0, result6["raw"] + harness.wait_until( + lambda: len(_matches(marker_ws, "m-icmp")) >= 10 and len(_matches(marker_ws, "m-icmp6")) >= 10, + timeout=10, + ) + # exact counts prove the two filters do not cross-fire: each + # marker sees its own family's 5 requests + 5 replies and + # nothing of the other's icmp = _matches(marker_ws, "m-icmp") assert len(icmp) == 10, len(icmp) # 5 requests + 5 replies + icmp6 = _matches(marker_ws, "m-icmp6") + assert len(icmp6) == 10, len(icmp6) assert {e["dir"] for e in icmp} == {"tx", "rx"}, [e["dir"] for e in icmp] + assert {e["dir"] for e in icmp6} == {"tx", "rx"}, [e["dir"] for e in icmp6] for event in icmp: assert event["node_id"] == n1_id, event assert event["link_id"] == link["link_id"], event assert event["tag"] == 4242, event - assert event["len"] == 98, event # busybox ping frame - # the marker's pcap on the host holds exactly the matched frames + assert event["len"] == PING_FRAME_LEN, event # busybox ping frame + for event in icmp6: + assert event["node_id"] == n1_id, event + assert event["link_id"] == link["link_id"], event + assert event["tag"] == 4243, event + assert event["len"] == PING_FRAME_LEN6, event # v6 echo is 20 bytes longer + # each marker's pcap on the host holds exactly its matched frames markers_dir = os.path.join(compute.call("GET", f"/projects/{pid}")["path"], "project-files", "markers") pcap = os.path.join(markers_dir, f"{n1_id}_{link['link_id']}_m-icmp.pcap") + pcap6 = os.path.join(markers_dir, f"{n1_id}_{link['link_id']}_m-icmp6.pcap") assert os.path.exists(pcap), os.listdir(markers_dir) + assert os.path.exists(pcap6), os.listdir(markers_dir) count, ethertypes = harness.pcap_records(pcap) assert count == 10, count assert set(ethertypes) == {"0800"}, ethertypes - # deleting the marker removes its pcap with it + count6, ethertypes6 = harness.pcap_records(pcap6) + assert count6 == 10, count6 + assert set(ethertypes6) == {"86dd"}, ethertypes6 + # deleting a marker removes its pcap with it + compute.call("DELETE", f"/projects/{pid}/links/{link['link_id']}/markers/m-icmp6") + assert harness.wait_until(lambda: not os.path.exists(pcap6), timeout=10), pcap6 compute.call("DELETE", f"/projects/{pid}/links/{link['link_id']}/markers/m-icmp") assert harness.wait_until(lambda: not os.path.exists(pcap), timeout=10), pcap finally: @@ -618,9 +787,10 @@ def test_docker_relay_to_kernel_reopen_upgrade(): assert link["kernel_datapath"] is False, link bridge = harness.link_bridge_name(link["link_id"]) - _start_addresses(compute, pid, daemon, (n1, n2), (R1_IP, R2_IP)) + _start_addresses(compute, pid, daemon, (n1, n2), ((R1_IP, R1_IP6), (R2_IP, R2_IP6))) assert harness.bridge_members(bridge) is None assert harness.docker_wait_ping(daemon, n1["container_id"], R2_IP)["loss"] == 0 + assert harness.docker_wait_ping(daemon, n1["container_id"], R2_IP6)["loss"] == 0 # Flip the datapath choice and reopen the project: every link # re-runs _prepare, which re-evaluates eligibility live. @@ -634,9 +804,10 @@ def test_docker_relay_to_kernel_reopen_upgrade(): assert link["kernel_datapath"] is True, link assert not harness.tap_exists(a1), "containers are stopped after the reopen" - _start_addresses(compute, pid, daemon, (n1, n2), (R1_IP, R2_IP)) + _start_addresses(compute, pid, daemon, (n1, n2), ((R1_IP, R1_IP6), (R2_IP, R2_IP6))) assert harness.wait_until(lambda: harness.bridge_members(bridge) == sorted([a1, a2]), timeout=15) assert harness.docker_wait_ping(daemon, n1["container_id"], R2_IP, attempts=5)["loss"] == 0 + assert harness.docker_wait_ping(daemon, n1["container_id"], R2_IP6, attempts=5)["loss"] == 0 except BaseException: harness.release(server, pid, failed=True) raise diff --git a/tests/e2e/test_docker_link_local_frames.py b/tests/e2e/test_docker_link_local_frames.py index 42743d83a..216bb407d 100644 --- a/tests/e2e/test_docker_link_local_frames.py +++ b/tests/e2e/test_docker_link_local_frames.py @@ -36,7 +36,12 @@ asserts, through the server's own wiring path: * 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; + ``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. @@ -65,15 +70,18 @@ ETH = (0, 0) R1_IP = "10.2.1.1" R2_IP = "10.2.1.2" -# (destination MAC, ethertype) — what the guest protocol would emit +# (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, STP, LACP, EAPOL, LLDP] +MUST_CROSS = [CONTROL, CONTROL6, STP, LACP, EAPOL, LLDP] MUST_NOT_CROSS = [PAUSE] RECV_WINDOW = 12 diff --git a/tests/e2e/test_dynamips_kernel_datapath.py b/tests/e2e/test_dynamips_kernel_datapath.py index 5d170eb98..9e1bd6154 100644 --- a/tests/e2e/test_dynamips_kernel_datapath.py +++ b/tests/e2e/test_dynamips_kernel_datapath.py @@ -39,6 +39,10 @@ per-link bridge, nothing enslaved — and still pings, so the kernel objects above can only come from the kernel datapath. (The anchors themselves exist on both: they are the adapter's port interfaces, created by node start whenever the tap module is available, exactly like QEMU's TAPs.) + +Every Ethernet checkpoint runs dual-stack (the routers carry a ULA v6 +address next to their v4 one and each checkpoint pings both families); the +serial leg stays v4-only — it is HDLC on the relay, not an Ethernet wire. """ import re @@ -56,6 +60,11 @@ SLOTS = {"slot1": "PA-2FE-TX", "slot2": "PA-4T+"} ETH = (1, 0) SERIAL = (2, 0) +# the dual-stack half of the Ethernet leg: a ULA mirroring 10.1.1.x. The +# serial leg stays v4-only (HDLC, not an Ethernet wire). +R1_IP6 = "fd00:1:1::1" +R2_IP6 = "fd00:1:1::2" + def _pick_image(server): images = server.compute.dynamips_images() @@ -134,13 +143,19 @@ def test_dynamips_kernel_datapath(): harness.assert_forwarding(t1) harness.assert_forwarding(t2) - harness.configure_ios(c1, "R1", eth_ip="10.1.1.1") - harness.configure_ios(c2, "R2", eth_ip="10.1.1.2") + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_ipv6=R1_IP6) + harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_ipv6=R2_IP6) out = c1.run("show ip int brief") assert re.search(r"FastEthernet1/0\s+10\.1\.1\.1\s+\S+\s+\S+\s+up\s+up", out), out + out = c1.run("show ipv6 interface brief") + # IOS prints v6 addresses upper-case (and on their own line): match + # the literal case-insensitively + assert R1_IP6.upper() in out.upper(), out print(".. both routers configured, pinging over the kernel link") baseline = harness.wait_ping(c1, "10.1.1.2") assert baseline["success"] == 100, baseline["raw"] + baseline6 = harness.wait_ping(c1, R2_IP6) + assert baseline6["success"] == 100, baseline6["raw"] # L2-anchor spec §E.2: shut the guest interfaces (silencing IOS's own # CDP/keepalives — the only legitimate speakers on the segment) and @@ -153,9 +168,11 @@ def test_dynamips_kernel_datapath(): for console in (c1, c2): _eth_shutdown(console, shut=False) assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100 # delay 100: netem on both anchors (each impairs one direction), - # one-way ~100 ms => RTT grows by ~200 ms + # one-way ~100 ms => RTT grows by ~200 ms — netem is family-blind, + # both families ride the same qdisc compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"delay": [100]}}) assert "netem" in harness.qdiscs(t1) and "netem" in harness.qdiscs(t2), ( harness.qdiscs(t1), @@ -163,30 +180,54 @@ def test_dynamips_kernel_datapath(): ) delayed = harness.wait_ping(c1, "10.1.1.2") assert delayed["success"] == 100, delayed["raw"] + delayed6 = harness.wait_ping(c1, R2_IP6) + assert delayed6["success"] == 100, delayed6["raw"] + print(f".. delay 100: v4 {baseline['avg']}->{delayed['avg']} ms, v6 {baseline6['avg']}->{delayed6['avg']} ms") assert delayed["avg"] >= 150, (baseline, delayed) + # The v6 twin is asserted from the capture's own timestamps, not from + # the guest's report: the dynamips-emulated classic IOS under-reports + # its ping ipv6 RTT by ~40 % of a delayed round trip (reproducible: + # report ~125-135 ms while a pcap on the anchor shows the v6 echo + # request->reply taking the same ~230 ms as v4 — the wire is correct, + # the reporter is not; the pings' order does not matter). + capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"}) + harness.ping(c1, R2_IP6) + time.sleep(1) + compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop") + wire6 = harness.pcap_icmp_rtt(capture["capture_file_path"], "86dd") + print(f".. delay 100 (capture): v6 wire round trip {wire6:.0f} ms") + assert wire6 is not None and wire6 >= 60, wire6 compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}}) assert "netem" not in harness.qdiscs(t1), harness.qdiscs(t1) fast = harness.wait_ping(c1, "10.1.1.2") assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, fast + fast6 = harness.wait_ping(c1, R2_IP6) + assert fast6["success"] == 100 and fast6["avg"] < harness.FAST_RTT_MS, fast6 # suspend: anchor admin-down, traffic dead; resume restores it compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": True}) assert not harness.tap_up(t1) dead = harness.ping(c1, "10.1.1.2", repeat=3) assert dead["success"] == 0, dead["raw"] + dead6 = harness.ping(c1, R2_IP6, repeat=3) + assert dead6["success"] == 0, dead6["raw"] compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": False}) assert harness.tap_up(t1) assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100 + assert harness.wait_ping(c1, R2_IP6)["success"] == 100 - # capture: AF_PACKET on the anchor writes a real pcap + # capture: AF_PACKET on the anchor writes a real pcap — both + # families inside the window, both ethertypes on the wire capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"}) harness.ping(c1, "10.1.1.2") + harness.ping(c1, R2_IP6) time.sleep(1) compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop") path = capture["capture_file_path"] count, ethertypes = harness.pcap_records(path) - assert count >= 4, f"{path}: {count} records" + assert count >= 8, f"{path}: {count} records" assert "0800" in ethertypes, (path, ethertypes) + assert "86dd" in ethertypes, (path, ethertypes) # serial link between the same routers: per-port exclusion keeps it # on the relay, where the real traffic still crosses @@ -208,6 +249,7 @@ def test_dynamips_kernel_datapath(): bridge = harness.link_bridge_name(lid) assert link["kernel_datapath"] is True, link assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100 # stop / start keeps the whole wiring (the Dynamips hypervisor # outlives ``vm stop``); the restarted router just reboots and is @@ -220,8 +262,9 @@ def test_dynamips_kernel_datapath(): c1.close() c1 = _boot(server, pid, r1_id) assert harness.bridge_members(bridge) == sorted([t1, t2]) - harness.configure_ios(c1, "R1", eth_ip="10.1.1.1") + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_ipv6=R1_IP6) assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100 c1.close() c2.close() @@ -281,10 +324,12 @@ def test_dynamips_relay_control(): # datapath one: relay anchors are pure L2 too harness.assert_pure_l2(t1) - harness.configure_ios(c1, "R1", eth_ip="10.1.1.1") - harness.configure_ios(c2, "R2", eth_ip="10.1.1.2") + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_ipv6=R1_IP6) + harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_ipv6=R2_IP6) relay_ping = harness.wait_ping(c1, "10.1.1.2") assert relay_ping["success"] == 100, relay_ping["raw"] + relay_ping6 = harness.wait_ping(c1, R2_IP6) + assert relay_ping6["success"] == 100, relay_ping6["raw"] c1.close() c2.close() diff --git a/tests/e2e/test_ethernet_switch_cascade.py b/tests/e2e/test_ethernet_switch_cascade.py index c474a6124..fe72c8575 100644 --- a/tests/e2e/test_ethernet_switch_cascade.py +++ b/tests/e2e/test_ethernet_switch_cascade.py @@ -52,8 +52,15 @@ No relay control twin here: on the relay datapath a cascade is just two per-port uBridge UDP relays, the machinery ``test_ethernet_switch_kernel_datapath.py::test_ethernet_switch_relay_control`` already drives. + +Every checkpoint runs dual-stack: each router interface carries a ULA v6 +address next to its v4 one and every ping is doubled (both VLANs tagged +across the trunk in both families, the mode flip and suspend killing both, +the two-sided delay measured on both). """ +import time + import pytest from tests.e2e import harness @@ -68,6 +75,9 @@ ETH20 = (1, 1) IP10 = "10.1.10.%s" IP20 = "10.1.20.%s" +# the dual-stack half: ULAs mirroring the v4 subnets +IP10_6 = "fd00:1:10::%s" +IP20_6 = "fd00:1:20::%s" def _pick_image(server): @@ -110,10 +120,12 @@ def _set_ports(compute, pid, switch_id, cascade_type): compute.call("PUT", f"/projects/{pid}/nodes/{switch_id}", {"properties": {"ports_mapping": _ports(cascade_type)}}) -def _add_eth(console, ip, ifname): +def _add_eth(console, ip, ifname, ipv6=None): console.run("conf t") console.run(f"interface {ifname}") console.run(f"ip address {ip} 255.255.255.0") + if ipv6: + console.run(f"ipv6 address {ipv6}/64") console.run("no shutdown") console.run("end") @@ -202,18 +214,23 @@ def test_ethernet_switch_cascade_kernel_datapath(): harness.bridge_members(br2) ) - harness.configure_ios(c1, "R1", eth_ip=IP10 % 1, eth_if="f1/0") - _add_eth(c1, IP20 % 1, "f1/1") - harness.configure_ios(c2, "R2", eth_ip=IP10 % 2, eth_if="f1/0") - _add_eth(c2, IP20 % 2, "f1/1") + harness.configure_ios(c1, "R1", eth_ip=IP10 % 1, eth_if="f1/0", eth_ipv6=IP10_6 % 1) + _add_eth(c1, IP20 % 1, "f1/1", ipv6=IP20_6 % 1) + harness.configure_ios(c2, "R2", eth_ip=IP10 % 2, eth_if="f1/0", eth_ipv6=IP10_6 % 2) + _add_eth(c2, IP20 % 2, "f1/1", ipv6=IP20_6 % 2) print(".. both routers configured, pinging across the cascade") # A trunk cascade carries both VLANs at once — two simultaneous - # adjacencies over the same veth pair is the tagged-frames proof. + # adjacencies over the same veth pair is the tagged-frames proof — + # in both families. baseline10 = harness.wait_ping(c1, IP10 % 2) assert baseline10["success"] == 100, baseline10["raw"] baseline20 = harness.wait_ping(c1, IP20 % 2) assert baseline20["success"] == 100, baseline20["raw"] + baseline10_6 = harness.wait_ping(c1, IP10_6 % 2) + assert baseline10_6["success"] == 100, baseline10_6["raw"] + baseline20_6 = harness.wait_ping(c1, IP20_6 % 2) + assert baseline20_6["success"] == 100, baseline20_6["raw"] # L2-anchor spec §E.2: with all four router interfaces shut, every # host-side device of this fabric stays silent for 5 s — the four @@ -228,6 +245,8 @@ def test_ethernet_switch_cascade_kernel_datapath(): _eth_shutdown(console, shut=False) assert harness.wait_ping(c1, IP10 % 2, attempts=5)["success"] == 100 assert harness.wait_ping(c1, IP20 % 2, attempts=5)["success"] == 100 + assert harness.wait_ping(c1, IP10_6 % 2, attempts=5)["success"] == 100 + assert harness.wait_ping(c1, IP20_6 % 2, attempts=5)["success"] == 100 else: print(".. uBridge without link l2only: skipping the §E.2 assertion") @@ -251,12 +270,17 @@ def test_ethernet_switch_cascade_kernel_datapath(): assert harness.bridge_members(br1) == sorted([a1_10, a1_20, e0]), harness.bridge_members(br1) assert harness.bridge_members(br2) == sorted([a2_10, a2_20, e1]), harness.bridge_members(br2) assert harness.wait_ping(c1, IP10 % 2)["success"] == 100 + assert harness.wait_ping(c1, IP10_6 % 2)["success"] == 100 isolated = harness.ping(c1, IP20 % 2, repeat=3) assert isolated["success"] == 0, isolated["raw"] + isolated6 = harness.ping(c1, IP20_6 % 2, repeat=3) + assert isolated6["success"] == 0, isolated6["raw"] _set_ports(compute, pid, sw1_id, "dot1q") _set_ports(compute, pid, sw2_id, "dot1q") rejoined = harness.wait_ping(c1, IP20 % 2, attempts=5) assert rejoined["success"] == 100, rejoined["raw"] + rejoined6 = harness.wait_ping(c1, IP20_6 % 2, attempts=5) + assert rejoined6["success"] == 100, rejoined6["raw"] # Two-sided impairment: each veth end is its own interface, so the # filter lands on both — one netem per direction, RTT ~= 2 x delay. @@ -266,20 +290,38 @@ def test_ethernet_switch_cascade_kernel_datapath(): delayed = harness.wait_ping(c1, IP10 % 2) assert delayed["success"] == 100, delayed["raw"] assert 150 <= delayed["avg"] <= 350, (baseline10, delayed) + # the v6 twin reads the capture's own timestamps: the guest's + # ping ipv6 under-reports a delayed round trip on dynamips (see + # test_dynamips_kernel_datapath), so this asserts the wire itself + capture = compute.call( + "POST", f"/projects/{pid}/links/{cascade['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"} + ) + harness.ping(c1, IP10_6 % 2) + time.sleep(1) + compute.call("POST", f"/projects/{pid}/links/{cascade['link_id']}/capture/stop") + wire6 = harness.pcap_icmp_rtt(capture["capture_file_path"], "86dd") + print(f".. delay 100: v4 {baseline10['avg']}->{delayed['avg']} ms, v6 wire {wire6:.0f} ms") + assert wire6 is not None and wire6 >= 60, wire6 compute.call("PUT", f"/projects/{pid}/links/{cascade['link_id']}", {"filters": {}}) assert "netem" not in harness.qdiscs(e0) and "netem" not in harness.qdiscs(e1) fast = harness.wait_ping(c1, IP10 % 2) assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, fast + fast6 = harness.wait_ping(c1, IP10_6 % 2) + assert fast6["success"] == 100 and fast6["avg"] < harness.FAST_RTT_MS, fast6 # suspend: both ends admin-down, both VLANs die; resume restores. compute.call("PUT", f"/projects/{pid}/links/{cascade['link_id']}", {"suspend": True}) assert not harness.tap_up(e0) and not harness.tap_up(e1) assert harness.ping(c1, IP10 % 2, repeat=3)["success"] == 0 assert harness.ping(c1, IP20 % 2, repeat=3)["success"] == 0 + assert harness.ping(c1, IP10_6 % 2, repeat=3)["success"] == 0 + assert harness.ping(c1, IP20_6 % 2, repeat=3)["success"] == 0 compute.call("PUT", f"/projects/{pid}/links/{cascade['link_id']}", {"suspend": False}) assert harness.tap_up(e0) and harness.tap_up(e1) assert harness.wait_ping(c1, IP10 % 2, attempts=5)["success"] == 100 assert harness.wait_ping(c1, IP20 % 2, attempts=5)["success"] == 100 + assert harness.wait_ping(c1, IP10_6 % 2, attempts=5)["success"] == 100 + assert harness.wait_ping(c1, IP20_6 % 2, attempts=5)["success"] == 100 # Cascade link fault: the whole veth pair dies with the link (either # side's teardown), the host links and their anchors survive; a @@ -290,6 +332,7 @@ def test_ethernet_switch_cascade_kernel_datapath(): assert harness.bridge_members(br1) == sorted([a1_10, a1_20]), harness.bridge_members(br1) assert harness.bridge_members(br2) == sorted([a2_10, a2_20]), harness.bridge_members(br2) assert harness.ping(c1, IP10 % 2, repeat=3)["success"] == 0 + assert harness.ping(c1, IP10_6 % 2, repeat=3)["success"] == 0 cascade = compute.create_link(pid, (sw1_id, 0, 2), (sw2_id, 0, 2)) assert cascade["kernel_datapath"] is True, cascade n0, n1 = harness.cascade_end_names(cascade["link_id"]) @@ -297,6 +340,8 @@ def test_ethernet_switch_cascade_kernel_datapath(): assert harness.wait_until(lambda: harness.bridge_members(br2) == sorted([a2_10, a2_20, n1]), timeout=10) assert harness.wait_ping(c1, IP10 % 2, attempts=5)["success"] == 100 assert harness.wait_ping(c1, IP20 % 2, attempts=5)["success"] == 100 + assert harness.wait_ping(c1, IP10_6 % 2, attempts=5)["success"] == 100 + assert harness.wait_ping(c1, IP20_6 % 2, attempts=5)["success"] == 100 c1.close() c2.close() diff --git a/tests/e2e/test_ethernet_switch_kernel_datapath.py b/tests/e2e/test_ethernet_switch_kernel_datapath.py index d42cd3a93..25bf19179 100644 --- a/tests/e2e/test_ethernet_switch_kernel_datapath.py +++ b/tests/e2e/test_ethernet_switch_kernel_datapath.py @@ -43,6 +43,10 @@ Real ICMP crossing the switch proves the datapath: isolated relay-configured instance: the same topology rides the per-port uBridge relays (the switch bridge's members are its own relay TAPs, never the routers' anchors) and still pings. + +Every checkpoint runs dual-stack: the routers carry a ULA v6 address next +to their v4 one and each checkpoint pings both families (VLAN isolation +and suspend kill both; the netem delays and the capture window carry both). """ import time @@ -57,6 +61,10 @@ pytestmark = pytest.mark.e2e SLOTS = {"slot1": "PA-2FE-TX"} ETH = (1, 0) +# the dual-stack half: a ULA mirroring 10.1.1.x +R1_IP6 = "fd00:1:1::1" +R2_IP6 = "fd00:1:1::2" + def _pick_image(server): images = server.compute.dynamips_images() @@ -161,11 +169,13 @@ def test_ethernet_switch_kernel_fast_path(): harness.bridge_members(sw_bridge) ) - harness.configure_ios(c1, "R1", eth_ip="10.1.1.1") - harness.configure_ios(c2, "R2", eth_ip="10.1.1.2") + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_ipv6=R1_IP6) + harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_ipv6=R2_IP6) print(".. both routers configured, pinging through the switch") baseline = harness.wait_ping(c1, "10.1.1.2") assert baseline["success"] == 100, baseline["raw"] + baseline6 = harness.wait_ping(c1, R2_IP6) + assert baseline6["success"] == 100, baseline6["raw"] # L2-anchor spec §E.2: with both routers shut, the absorbed anchors # and the switch bridge stay silent for 5 s (the bridge is created @@ -177,6 +187,7 @@ def test_ethernet_switch_kernel_fast_path(): for console in (c1, c2): _eth_shutdown(console, shut=False) assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100 else: print(".. uBridge without link l2only: skipping the §E.2 assertion") @@ -209,6 +220,8 @@ def test_ethernet_switch_kernel_fast_path(): assert harness.bridge_members(sw_bridge) == sorted([a1, a2]), harness.bridge_members(sw_bridge) isolated = harness.ping(c1, "10.1.1.2", repeat=3) assert isolated["success"] == 0, isolated["raw"] + isolated6 = harness.ping(c1, R2_IP6, repeat=3) + assert isolated6["success"] == 0, isolated6["raw"] assert harness.bridge_members(sw_bridge) == sorted([a1, a2]), harness.bridge_members(sw_bridge) compute.call( "PUT", @@ -224,6 +237,8 @@ def test_ethernet_switch_kernel_fast_path(): ) rejoined = harness.wait_ping(c1, "10.1.1.2") assert rejoined["success"] == 100, rejoined["raw"] + rejoined6 = harness.wait_ping(c1, R2_IP6) + assert rejoined6["success"] == 100, rejoined6["raw"] # Single-owned impairment: the filter lands on the absorbed anchor # (one interface — the switch's), RTT grows by ~100 ms one-way. @@ -233,10 +248,25 @@ def test_ethernet_switch_kernel_fast_path(): delayed = harness.wait_ping(c1, "10.1.1.2") assert delayed["success"] == 100, delayed["raw"] assert 60 <= delayed["avg"] <= 250, (baseline, delayed) + # the v6 twin reads the capture's own timestamps: the guest's + # ping ipv6 under-reports a delayed round trip on dynamips (see + # test_dynamips_kernel_datapath), and here the single-owned delay + # is small enough that the quirk swallows most of the signal + capture = compute.call( + "POST", f"/projects/{pid}/links/{link1['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"} + ) + harness.ping(c1, R2_IP6) + time.sleep(1) + compute.call("POST", f"/projects/{pid}/links/{link1['link_id']}/capture/stop") + wire6 = harness.pcap_icmp_rtt(capture["capture_file_path"], "86dd") + print(f".. delay 100: v4 {baseline['avg']}->{delayed['avg']} ms, v6 wire {wire6:.0f} ms") + assert wire6 is not None and wire6 >= 60, wire6 compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"filters": {}}) assert "netem" not in harness.qdiscs(a1) fast = harness.wait_ping(c1, "10.1.1.2") assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, fast + fast6 = harness.wait_ping(c1, R2_IP6) + assert fast6["success"] == 100 and fast6["avg"] < harness.FAST_RTT_MS, fast6 # The same single-owned impairment on link2 — created switch-first, # the order whose peer NIO update runs LAST. The passive end's empty @@ -249,31 +279,54 @@ def test_ethernet_switch_kernel_fast_path(): delayed2 = harness.wait_ping(c1, "10.1.1.2") assert delayed2["success"] == 100, delayed2["raw"] assert 60 <= delayed2["avg"] <= 250, (baseline, delayed2) + # the capture must sit on the pinging node's anchor (link1's, i.e. + # R1's): a TX copy is timestamped post-qdisc, so on link2's own + # anchor — where this window's delay impairs the request direction — + # the request copy would already carry the delay and the measured + # delta would collapse to the turnaround. R1's anchor sees the + # request before the delay and the reply after it. + capture = compute.call( + "POST", f"/projects/{pid}/links/{link1['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"} + ) + harness.ping(c1, R2_IP6) + time.sleep(1) + compute.call("POST", f"/projects/{pid}/links/{link1['link_id']}/capture/stop") + wire2_6 = harness.pcap_icmp_rtt(capture["capture_file_path"], "86dd") + print(f".. delay 100 (link2): v4 {baseline['avg']}->{delayed2['avg']} ms, v6 wire {wire2_6:.0f} ms") + assert wire2_6 is not None and wire2_6 >= 60, wire2_6 compute.call("PUT", f"/projects/{pid}/links/{link2['link_id']}", {"filters": {}}) assert "netem" not in harness.qdiscs(a2) fast2 = harness.wait_ping(c1, "10.1.1.2") assert fast2["success"] == 100 and fast2["avg"] < harness.FAST_RTT_MS, fast2 + fast2_6 = harness.wait_ping(c1, R2_IP6) + assert fast2_6["success"] == 100 and fast2_6["avg"] < harness.FAST_RTT_MS, fast2_6 # suspend: the anchor admin-downs, the link is dead; resume restores compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"suspend": True}) assert not harness.tap_up(a1) dead = harness.ping(c1, "10.1.1.2", repeat=3) assert dead["success"] == 0, dead["raw"] + dead6 = harness.ping(c1, R2_IP6, repeat=3) + assert dead6["success"] == 0, dead6["raw"] compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"suspend": False}) assert harness.tap_up(a1) assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100 + assert harness.wait_ping(c1, R2_IP6)["success"] == 100 # capture (the controller hosts it on the switch — always-on local - # builtin): a pcap of ICMP records written off the absorbed anchor + # builtin): a pcap of ICMP records written off the absorbed anchor, + # both families inside the window capture = compute.call( "POST", f"/projects/{pid}/links/{link1['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"} ) harness.ping(c1, "10.1.1.2") + harness.ping(c1, R2_IP6) time.sleep(1) compute.call("POST", f"/projects/{pid}/links/{link1['link_id']}/capture/stop") count, ethertypes = harness.pcap_records(capture["capture_file_path"]) - assert count >= 4, capture["capture_file_path"] + assert count >= 8, capture["capture_file_path"] assert "0800" in ethertypes, ethertypes + assert "86dd" in ethertypes, ethertypes # link delete detaches the anchor (it survives — the router owns # it); re-creating re-joins it @@ -284,6 +337,7 @@ def test_ethernet_switch_kernel_fast_path(): assert link2["kernel_datapath"] is True, link2 assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=10) assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100 # router stop/start keeps the wiring: a Dynamips stop only halts the # emulated router — the hypervisor, the anchor and its bridge @@ -297,8 +351,9 @@ def test_ethernet_switch_kernel_fast_path(): c1.close() c1 = _boot(server, pid, r1_id) assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=15) - harness.configure_ios(c1, "R1", eth_ip="10.1.1.1") + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_ipv6=R1_IP6) assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100 c1.close() c2.close() @@ -351,10 +406,12 @@ def test_ethernet_switch_relay_control(): harness.assert_pure_l2(member) harness.assert_pure_l2(sw_bridge) - harness.configure_ios(c1, "R1", eth_ip="10.1.1.1") - harness.configure_ios(c2, "R2", eth_ip="10.1.1.2") + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_ipv6=R1_IP6) + harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_ipv6=R2_IP6) relay_ping = harness.wait_ping(c1, "10.1.1.2") assert relay_ping["success"] == 100, relay_ping["raw"] + relay_ping6 = harness.wait_ping(c1, R2_IP6) + assert relay_ping6["success"] == 100, relay_ping6["raw"] c1.close() c2.close() diff --git a/tests/e2e/test_iol_docker_kernel_datapath.py b/tests/e2e/test_iol_docker_kernel_datapath.py index 9100aaf9b..d64e8cb7f 100644 --- a/tests/e2e/test_iol_docker_kernel_datapath.py +++ b/tests/e2e/test_iol_docker_kernel_datapath.py @@ -36,6 +36,12 @@ bridge, kernel filters, suspend and capture. ``test_iol_docker_relay_control`` is the negative control on an isolated relay-configured instance: the same topology rides unix ↔ UDP through the uBridge relay (no anchors ever exist) and still pings. + +Every checkpoint runs dual-stack: the IOS-XE guests carry a ULA v6 address +next to their v4 one and each checkpoint pings both families — including +the TAP-anchor bpf spot check (the libpcap expression is family-specific: +each drop filter is asserted to kill its own family and leave the other +alive) and the eBPF nth classifier (family-blind, asserted on both). """ import re @@ -51,6 +57,10 @@ pytestmark = pytest.mark.e2e ETH = (0, 0) BOOT_TIMEOUT = 360 +# the dual-stack half: a ULA mirroring 10.1.1.x +R1_IP6 = "fd00:1:1::1" +R2_IP6 = "fd00:1:1::2" + def _pick_image(server): images = server.compute.docker_images() @@ -143,13 +153,17 @@ def test_iol_docker_kernel_datapath(): harness.assert_forwarding(a1) harness.assert_forwarding(a2) - harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0") - harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if="Ethernet0/0") + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0", eth_ipv6=R1_IP6) + harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if="Ethernet0/0", eth_ipv6=R2_IP6) out = c1.run("show ip int brief") assert re.search(r"Ethernet0/0\s+10\.1\.1\.1\s+\S+\s+\S+\s+up\s+up", out), out + out = c1.run("show ipv6 interface brief") + assert R1_IP6.upper() in out.upper(), out # IOS prints v6 upper-case print(".. both routers configured, pinging through the port-bridge swap") baseline = harness.wait_ping(c1, "10.1.1.2") assert baseline["success"] == 100, baseline["raw"] + baseline6 = harness.wait_ping(c1, R2_IP6) + assert baseline6["success"] == 100, baseline6["raw"] # L2-anchor spec §E.2: with the guests shut the anchors and the # per-link bridge stay silent for 5 s. These anchors come from @@ -163,6 +177,7 @@ def test_iol_docker_kernel_datapath(): for console in (c1, c2): _eth_shutdown(console, shut=False) assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100 else: print(".. uBridge without link l2only: skipping the §E.2 assertion") @@ -176,10 +191,15 @@ def test_iol_docker_kernel_datapath(): delayed = harness.wait_ping(c1, "10.1.1.2") assert delayed["success"] == 100, delayed["raw"] assert delayed["avg"] >= 150, (baseline, delayed) + delayed6 = harness.wait_ping(c1, R2_IP6) + assert delayed6["success"] == 100, delayed6["raw"] + assert delayed6["avg"] >= 150, (baseline6, delayed6) compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}}) assert "netem" not in harness.qdiscs(a1), harness.qdiscs(a1) fast = harness.wait_ping(c1, "10.1.1.2") assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, fast + fast6 = harness.wait_ping(c1, R2_IP6) + assert fast6["success"] == 100 and fast6["avg"] < harness.FAST_RTT_MS, fast6 # Classifier spot check on the container's TAP anchor (the Docker # suite runs the full matrix on veth host ends): cls_bpf match-drop @@ -187,7 +207,7 @@ def test_iol_docker_kernel_datapath(): # same way. tc_caps = (caps or {}).get("ubridge_tc") or {} if tc_caps.get("cbpf"): - print(".. bpf 'icmp' drops everything on the TAP anchor") + print(".. bpf 'icmp' drops v4 ICMP only on the TAP anchor") compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"bpf": ["icmp"]}}) assert "clsact" in harness.qdiscs(a1) and "clsact" in harness.qdiscs(a2), ( harness.qdiscs(a1), @@ -195,9 +215,18 @@ def test_iol_docker_kernel_datapath(): ) blocked = harness.ping(c1, "10.1.1.2", repeat=4) assert blocked["success"] == 0, blocked["raw"] + v6_alive = harness.wait_ping(c1, R2_IP6) + assert v6_alive["success"] == 100, v6_alive["raw"] + print(".. bpf 'icmp6' drops v6 ICMP only on the TAP anchor") + compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"bpf": ["icmp6"]}}) + v4_alive = harness.wait_ping(c1, "10.1.1.2") + assert v4_alive["success"] == 100, v4_alive["raw"] + blocked6 = harness.ping(c1, R2_IP6, repeat=4) + assert blocked6["success"] == 0, blocked6["raw"] compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}}) assert "clsact" not in harness.qdiscs(a1), harness.qdiscs(a1) assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100 + assert harness.wait_ping(c1, R2_IP6)["success"] == 100 else: print(".. uBridge reports no cbpf: skipping the bpf check") @@ -210,8 +239,13 @@ def test_iol_docker_kernel_datapath(): print(f".. {loss} % round-trip loss") # the kernel counts per direction: 1 - (2/3)^2 = 55.6 % round trip assert 20 <= loss <= 90, nth["raw"] + nth6 = harness.ping(c1, R2_IP6, repeat=9) + loss6 = 100 - nth6["success"] + print(f".. {loss6} % round-trip loss (v6)") + assert 20 <= loss6 <= 90, nth6["raw"] compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}}) assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100 + assert harness.wait_ping(c1, R2_IP6)["success"] == 100 else: print(".. uBridge reports no eBPF nth: skipping the frequency_drop check") @@ -221,19 +255,25 @@ def test_iol_docker_kernel_datapath(): assert not harness.tap_up(a1) dead = harness.ping(c1, "10.1.1.2", repeat=3) assert dead["success"] == 0, dead["raw"] + dead6 = harness.ping(c1, R2_IP6, repeat=3) + assert dead6["success"] == 0, dead6["raw"] compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": False}) assert harness.tap_up(a1) assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100 + assert harness.wait_ping(c1, R2_IP6)["success"] == 100 - # capture: AF_PACKET on the anchor writes a real pcap + # capture: AF_PACKET on the anchor writes a real pcap — both + # families inside the window capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"}) harness.ping(c1, "10.1.1.2") + harness.ping(c1, R2_IP6) time.sleep(1) compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop") path = capture["capture_file_path"] count, ethertypes = harness.pcap_records(path) - assert count >= 4, f"{path}: {count} records" + assert count >= 8, f"{path}: {count} records" assert "0800" in ethertypes, (path, ethertypes) + assert "86dd" in ethertypes, (path, ethertypes) # delete / re-create the kernel link: the port bridge's TAP leg is # released (stop -> delete_nio_tap), the anchor survives (the node @@ -247,6 +287,7 @@ def test_iol_docker_kernel_datapath(): bridge = harness.link_bridge_name(lid) assert link["kernel_datapath"] is True, link assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100 # node stop: uBridge holds the anchor fds, so the port bridges go # first and the taps with them (a stopped container must not litter @@ -263,8 +304,9 @@ def test_iol_docker_kernel_datapath(): c1 = _boot(server, pid, n1_id) assert harness.tap_exists(a1) assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge) - harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0") + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0", eth_ipv6=R1_IP6) assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100 c1.close() c2.close() @@ -322,10 +364,12 @@ def test_iol_docker_relay_control(): # the L2 hardening is a creation-time property, not a # datapath one: relay anchors are pure L2 too harness.assert_pure_l2(a1) - harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0") - harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if="Ethernet0/0") + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0", eth_ipv6=R1_IP6) + harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if="Ethernet0/0", eth_ipv6=R2_IP6) relay_ping = harness.wait_ping(c1, "10.1.1.2") assert relay_ping["success"] == 100, relay_ping["raw"] + relay_ping6 = harness.wait_ping(c1, R2_IP6) + assert relay_ping6["success"] == 100, relay_ping6["raw"] c1.close() c2.close() diff --git a/tests/e2e/test_iou_kernel_datapath.py b/tests/e2e/test_iou_kernel_datapath.py index da4a22ddc..49a6bb48b 100644 --- a/tests/e2e/test_iou_kernel_datapath.py +++ b/tests/e2e/test_iou_kernel_datapath.py @@ -62,6 +62,16 @@ ETH = (0, 0) SERIAL = (2, 0) BOOT_TIMEOUT = 360 +# the dual-stack half of the Ethernet leg: a ULA mirroring 10.1.1.x. The +# serial leg stays v4-only (HDLC, not an Ethernet wire). +R1_IP6 = "fd00:1:1::1" +R2_IP6 = "fd00:1:1::2" + +# exact ICMPv6 echo match for the relay marker: a bare "icmp6" would also +# match IOS's own control frames (DAD/MLD/NS/NA) and break the exact-count +# asserts +ICMP6_ECHO_BPF = "icmp6 and (ip6[40] == 128 or ip6[40] == 129)" + # L3 images only: an L2 switching image does not take an address on its # switchports. The first is the image the kernel datapath was validated # with, the second the matching 17.15 build. @@ -162,13 +172,17 @@ def test_iou_kernel_datapath(): harness.assert_forwarding(a1) harness.assert_forwarding(a2) - harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0") - harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if="Ethernet0/0") + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0", eth_ipv6=R1_IP6) + harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if="Ethernet0/0", eth_ipv6=R2_IP6) out = c1.run("show ip int brief") assert re.search(r"Ethernet0/0\s+10\.1\.1\.1\s+\S+\s+\S+\s+up\s+up", out), out + out = c1.run("show ipv6 interface brief") + assert R1_IP6.upper() in out.upper(), out # IOS prints v6 upper-case print(".. both routers configured, pinging through the [IOL fabric <-> TAP] port bridge") baseline = harness.wait_ping(c1, "10.1.1.2") assert baseline["success"] == 100, baseline["raw"] + baseline6 = harness.wait_ping(c1, R2_IP6) + assert baseline6["success"] == 100, baseline6["raw"] # L2-anchor spec §E.2: with the guests shut the anchors and the # per-link bridge stay silent for 5 s (these anchors are the `tap @@ -180,6 +194,7 @@ def test_iou_kernel_datapath(): for console in (c1, c2): _eth_shutdown(console, shut=False) assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100 else: print(".. uBridge without link l2only: skipping the §E.2 assertion") @@ -193,17 +208,22 @@ def test_iou_kernel_datapath(): delayed = harness.wait_ping(c1, "10.1.1.2") assert delayed["success"] == 100, delayed["raw"] assert delayed["avg"] >= 150, (baseline, delayed) + delayed6 = harness.wait_ping(c1, R2_IP6) + assert delayed6["success"] == 100, delayed6["raw"] + assert delayed6["avg"] >= 150, (baseline6, delayed6) compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}}) assert "netem" not in harness.qdiscs(a1), harness.qdiscs(a1) fast = harness.wait_ping(c1, "10.1.1.2") assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, fast + fast6 = harness.wait_ping(c1, R2_IP6) + assert fast6["success"] == 100 and fast6["avg"] < harness.FAST_RTT_MS, fast6 # Classifier spot check on a TAP anchor (the Docker suite runs the # full matrix on veth host ends): cls_bpf match-drop and the eBPF # stateful classifier attach to a tun/tap anchor the same way. tc_caps = (caps or {}).get("ubridge_tc") or {} if tc_caps.get("cbpf"): - print(".. bpf 'icmp' drops everything on the TAP anchor") + print(".. bpf 'icmp' drops v4 ICMP only on the TAP anchor") compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"bpf": ["icmp"]}}) assert "clsact" in harness.qdiscs(a1) and "clsact" in harness.qdiscs(a2), ( harness.qdiscs(a1), @@ -211,9 +231,18 @@ def test_iou_kernel_datapath(): ) blocked = harness.ping(c1, "10.1.1.2", repeat=4) assert blocked["success"] == 0, blocked["raw"] + v6_alive = harness.wait_ping(c1, R2_IP6) + assert v6_alive["success"] == 100, v6_alive["raw"] + print(".. bpf 'icmp6' drops v6 ICMP only on the TAP anchor") + compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"bpf": ["icmp6"]}}) + v4_alive = harness.wait_ping(c1, "10.1.1.2") + assert v4_alive["success"] == 100, v4_alive["raw"] + blocked6 = harness.ping(c1, R2_IP6, repeat=4) + assert blocked6["success"] == 0, blocked6["raw"] compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}}) assert "clsact" not in harness.qdiscs(a1), harness.qdiscs(a1) assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100 + assert harness.wait_ping(c1, R2_IP6)["success"] == 100 else: print(".. uBridge reports no cbpf: skipping the bpf check") @@ -226,8 +255,13 @@ def test_iou_kernel_datapath(): print(f".. {loss} % round-trip loss") # the kernel counts per direction: 1 - (2/3)^2 = 55.6 % round trip assert 20 <= loss <= 90, nth["raw"] + nth6 = harness.ping(c1, R2_IP6, repeat=9) + loss6 = 100 - nth6["success"] + print(f".. {loss6} % round-trip loss (v6)") + assert 20 <= loss6 <= 90, nth6["raw"] compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}}) assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100 + assert harness.wait_ping(c1, R2_IP6)["success"] == 100 else: print(".. uBridge reports no eBPF nth: skipping the frequency_drop check") @@ -237,19 +271,25 @@ def test_iou_kernel_datapath(): assert not harness.tap_up(a1) dead = harness.ping(c1, "10.1.1.2", repeat=3) assert dead["success"] == 0, dead["raw"] + dead6 = harness.ping(c1, R2_IP6, repeat=3) + assert dead6["success"] == 0, dead6["raw"] compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": False}) assert harness.tap_up(a1) assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100 + assert harness.wait_ping(c1, R2_IP6)["success"] == 100 - # capture: AF_PACKET on the anchor writes a real pcap + # capture: AF_PACKET on the anchor writes a real pcap — both + # families inside the window capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"}) harness.ping(c1, "10.1.1.2") + harness.ping(c1, R2_IP6) time.sleep(1) compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop") path = capture["capture_file_path"] count, ethertypes = harness.pcap_records(path) - assert count >= 4, f"{path}: {count} records" + assert count >= 8, f"{path}: {count} records" assert "0800" in ethertypes, (path, ethertypes) + assert "86dd" in ethertypes, (path, ethertypes) # serial link between the same nodes: per-port exclusion keeps it on # the relay, where the real traffic still crosses (IOU-specific — @@ -274,6 +314,7 @@ def test_iou_kernel_datapath(): bridge = harness.link_bridge_name(lid) assert link["kernel_datapath"] is True, link assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100 # node stop: uBridge holds the anchor fds, so the IOL bridge goes # first and the taps with it (a stopped node must not litter the @@ -294,13 +335,18 @@ def test_iou_kernel_datapath(): assert harness.tap_exists(a1) assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge) assert harness.wait_until(lambda: "netem" in harness.qdiscs(a1), timeout=15), harness.qdiscs(a1) - harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0") + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0", eth_ipv6=R1_IP6) survivor = harness.wait_ping(c1, "10.1.1.2", attempts=5) assert survivor["success"] == 100, survivor["raw"] assert survivor["avg"] >= 150, (baseline, survivor) + survivor6 = harness.wait_ping(c1, R2_IP6, attempts=5) + assert survivor6["success"] == 100, survivor6["raw"] + assert survivor6["avg"] >= 150, (baseline6, survivor6) compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}}) cleared = harness.ping(c1, "10.1.1.2", repeat=3) assert cleared["success"] == 100 and cleared["avg"] < harness.FAST_RTT_MS, (baseline, cleared) + cleared6 = harness.ping(c1, R2_IP6, repeat=3) + assert cleared6["success"] == 100 and cleared6["avg"] < harness.FAST_RTT_MS, (baseline6, cleared6) c1.close() c2.close() @@ -361,10 +407,12 @@ def test_iou_relay_control(): # the L2 hardening is a creation-time property, not a # datapath one: relay anchors are pure L2 too harness.assert_pure_l2(a1) - harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0") - harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if="Ethernet0/0") + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0", eth_ipv6=R1_IP6) + harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if="Ethernet0/0", eth_ipv6=R2_IP6) relay_ping = harness.wait_ping(c1, "10.1.1.2") assert relay_ping["success"] == 100, relay_ping["raw"] + relay_ping6 = harness.wait_ping(c1, R2_IP6) + assert relay_ping6["success"] == 100, relay_ping6["raw"] # The relay link's filters really shape the wire on the IOU engine # too. delay 100: one delay line per direction (the port's IOL and @@ -378,9 +426,14 @@ def test_iou_relay_control(): delayed = harness.ping(c1, "10.1.1.2", repeat=5) assert delayed["success"] == 100, delayed["raw"] assert delayed["avg"] >= 150, (relay_ping, delayed) + delayed6 = harness.ping(c1, R2_IP6, repeat=5) + assert delayed6["success"] == 100, delayed6["raw"] + assert delayed6["avg"] >= 150, (relay_ping6, delayed6) compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}}) fast = harness.ping(c1, "10.1.1.2", repeat=3) assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, (relay_ping, fast) + fast6 = harness.ping(c1, R2_IP6, repeat=3) + assert fast6["success"] == 100 and fast6["avg"] < harness.FAST_RTT_MS, (relay_ping6, fast6) # frequency_drop shares one counter between the port's two # directions: on an alternating ping stream one whole direction dies @@ -390,9 +443,14 @@ def test_iou_relay_control(): dropped = harness.ping(c1, "10.1.1.2", repeat=6) print(f".. success {dropped['success']} %") assert dropped["success"] <= 50, dropped["raw"] + dropped6 = harness.ping(c1, R2_IP6, repeat=6) + print(f".. success {dropped6['success']} % (v6)") + assert dropped6["success"] <= 50, dropped6["raw"] compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}}) recovered = harness.wait_ping(c1, "10.1.1.2") assert recovered["success"] == 100, recovered["raw"] + recovered6 = harness.wait_ping(c1, R2_IP6) + assert recovered6["success"] == 100, recovered6["raw"] # capture on the relay: iol_bridge start_capture on the port's IOL # location (the IOU engine's own capture path, distinct from the @@ -400,16 +458,18 @@ def test_iou_relay_control(): print(".. iol_bridge relay capture: start_capture writes the ICMP exchange") capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"}) harness.ping(c1, "10.1.1.2", repeat=5) + harness.ping(c1, R2_IP6, repeat=5) time.sleep(1) compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop") path = capture["capture_file_path"] count, ethertypes = harness.pcap_records(path) - assert count >= 4, f"{path}: {count} records" + assert count >= 8, f"{path}: {count} records" assert "0800" in ethertypes, (path, ethertypes) + assert "86dd" in ethertypes, (path, ethertypes) # markers on the relay: iol_bridge add_packet_filter ... mark on the # port's IOL location, signalled over the dedicated marker WS - print(".. iol_bridge relay marker: 5 ICMP echoes through a mark filter") + print(".. iol_bridge relay markers: 5 v4 + 5 v6 ICMP echoes through mark filters") marker_ws = harness.WebSocketCollector(server, f"/projects/{pid}/notifications/markers/ws") try: m = compute.call( @@ -418,23 +478,50 @@ def test_iou_relay_control(): {"name": "m-icmp", "bpf": "icmp", "tag": 4242, "capture_node_id": n1_id}, ) assert m["capture_node_id"] == n1_id and m["enabled"] is True, m + m6 = compute.call( + "POST", + f"/projects/{pid}/links/{lid}/markers", + {"name": "m-icmp6", "bpf": ICMP6_ECHO_BPF, "tag": 4243, "capture_node_id": n1_id}, + ) + assert m6["capture_node_id"] == n1_id and m6["enabled"] is True, m6 result = harness.ping(c1, "10.1.1.2", repeat=5) assert result["success"] == 100, result["raw"] - harness.wait_until(lambda: len(_matches(marker_ws, "m-icmp")) >= 10, timeout=10) + result6 = harness.ping(c1, R2_IP6, repeat=5) + assert result6["success"] == 100, result6["raw"] + harness.wait_until( + lambda: len(_matches(marker_ws, "m-icmp")) >= 10 and len(_matches(marker_ws, "m-icmp6")) >= 10, + timeout=10, + ) + # exact counts across both families: neither marker sees the + # other family's frames icmp = _matches(marker_ws, "m-icmp") assert len(icmp) == 10, len(icmp) # 5 requests + 5 replies + icmp6 = _matches(marker_ws, "m-icmp6") + assert len(icmp6) == 10, len(icmp6) assert {e["dir"] for e in icmp} == {"tx", "rx"}, [e["dir"] for e in icmp] + assert {e["dir"] for e in icmp6} == {"tx", "rx"}, [e["dir"] for e in icmp6] for event in icmp: assert event["node_id"] == n1_id, event assert event["link_id"] == lid, event assert event["tag"] == 4242, event + for event in icmp6: + assert event["node_id"] == n1_id, event + assert event["link_id"] == lid, event + assert event["tag"] == 4243, event markers_dir = os.path.join(compute.call("GET", f"/projects/{pid}")["path"], "project-files", "markers") pcap = os.path.join(markers_dir, f"{n1_id}_{lid}_m-icmp.pcap") + pcap6 = os.path.join(markers_dir, f"{n1_id}_{lid}_m-icmp6.pcap") assert os.path.exists(pcap), os.listdir(markers_dir) + assert os.path.exists(pcap6), os.listdir(markers_dir) count, ethertypes = harness.pcap_records(pcap) assert count == 10, count assert set(ethertypes) == {"0800"}, ethertypes - # deleting the marker removes its pcap with it + count6, ethertypes6 = harness.pcap_records(pcap6) + assert count6 == 10, count6 + assert set(ethertypes6) == {"86dd"}, ethertypes6 + # deleting a marker removes its pcap with it + compute.call("DELETE", f"/projects/{pid}/links/{lid}/markers/m-icmp6") + assert harness.wait_until(lambda: not os.path.exists(pcap6), timeout=10), pcap6 compute.call("DELETE", f"/projects/{pid}/links/{lid}/markers/m-icmp") assert harness.wait_until(lambda: not os.path.exists(pcap), timeout=10), pcap finally: diff --git a/tests/e2e/test_marker_kernel_datapath.py b/tests/e2e/test_marker_kernel_datapath.py index fba6b5d3f..ef988b279 100644 --- a/tests/e2e/test_marker_kernel_datapath.py +++ b/tests/e2e/test_marker_kernel_datapath.py @@ -40,6 +40,13 @@ the dedicated marker notification WebSocket. Real ICMP proves: marker under the tag is still capturing (skipped when sharkd is absent); * deleting a marker removes its pcap and silences its signals; deleting the project leaves no marker state behind. + +Everything runs dual-stack: alongside the v4 markers the link carries v6 +twin markers (one of them directioned) whose expression matches ICMPv6 +echoes only — a bare ``icmp6`` would also match the guests' own DAD/MLD +control frames and break the exact-count asserts. The exact counts across +both families double as the cross-family check: neither family's traffic +reaches the other family's markers. """ import json @@ -59,10 +66,19 @@ pytestmark = pytest.mark.e2e ETH = (0, 0) R1_IP = "10.1.1.1" R2_IP = "10.1.1.2" +# the dual-stack half: a ULA mirroring the v4 addressing (10.1.1.x -> fd00:1:1::x) +R1_IP6 = "fd00:1:1::1" +R2_IP6 = "fd00:1:1::2" TAG = 4242 +TAG6 = 4243 -# a busybox `ping` default frame: 14 Ethernet + 20 IP + 8 ICMP + 56 data. +# busybox `ping` default frames: 14 Ethernet + 20/40 IP + 8 ICMP + 56 data. PING_FRAME_LEN = 98 +PING_FRAME_LEN6 = 118 + +# exact ICMPv6 echo match: a bare "icmp6" would also match the guests' own +# DAD/MLD/NS/NA control frames and break the exact-count asserts +ICMP6_ECHO_BPF = "icmp6 and (ip6[40] == 128 or ip6[40] == 129)" def _resolve_container(daemon, node): @@ -87,12 +103,15 @@ def _create_topology(server, pid, image): def _start_topology(compute, pid, daemon, nodes): - """Start the containers and wait for init.sh to have applied their - addresses (busybox ifup runs a moment after container start).""" + """Start the containers and wait for init.sh to have applied their v4 + addresses (busybox ifup runs a moment after container start); the v6 + addresses are brought up by the harness after each start.""" 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=False): + for node, (address, address6) in zip(nodes, ((R1_IP, R1_IP6), (R2_IP, R2_IP6)), strict=False): harness.docker_wait_address(daemon, node["container_id"], address) + harness.docker_bring_up_ipv6(daemon, node["container_id"], address6) + harness.docker_wait_address(daemon, node["container_id"], address6) def _raw(compute, path): @@ -140,6 +159,8 @@ def test_marker_kernel_datapath(): _start_topology(compute, pid, daemon, (n1, n2)) baseline = harness.docker_wait_ping(daemon, c1, R2_IP) assert baseline["loss"] == 0, baseline["raw"] + baseline6 = harness.docker_wait_ping(daemon, c1, R2_IP6) + assert baseline6["loss"] == 0, baseline6["raw"] a1 = harness.docker_anchor_name(n1_id, *ETH) markers_dir = os.path.join(compute.call("GET", f"/projects/{pid}")["path"], "project-files", "markers") @@ -167,16 +188,39 @@ def test_marker_kernel_datapath(): {"name": "m-tx", "bpf": "icmp", "direction": "tx", "tag": 4344, "capture_node_id": n1_id}, ) assert m_tx["direction"] == "tx", m_tx + # the dual-stack twin: a v6 marker with an echo-specific expression + # (a bare "icmp6" would also match the guests' own NS/MLD/RA frames) + m6 = compute.call( + "POST", + f"/projects/{pid}/links/{lid}/markers", + {"name": "m-icmp6", "bpf": ICMP6_ECHO_BPF, "tag": TAG6, "capture_node_id": n1_id}, + ) + assert m6["capture_node_id"] == n1_id and m6["tag"] == TAG6 and m6["enabled"] is True, m6 + m_tx6 = compute.call( + "POST", + f"/projects/{pid}/links/{lid}/markers", + {"name": "m-tx6", "bpf": ICMP6_ECHO_BPF, "direction": "tx", "tag": 4345, "capture_node_id": n1_id}, + ) + assert m_tx6["direction"] == "tx", m_tx6 - print(".. sending 5 ICMP echoes and collecting marker.match signals") + print(".. sending 5 v4 + 5 v6 ICMP echoes and collecting marker.match signals") result = harness.docker_ping(daemon, c1, R2_IP, count=5) assert result["loss"] == 0, result["raw"] + result6 = harness.docker_ping(daemon, c1, R2_IP6, count=5) + assert result6["loss"] == 0, result6["raw"] - harness.wait_until(lambda: len(_matches(marker_ws, "m-icmp")) >= 10, timeout=10) + harness.wait_until( + lambda: len(_matches(marker_ws, "m-icmp")) >= 10 and len(_matches(marker_ws, "m-icmp6")) >= 10, timeout=10 + ) + # exact counts across both families: each marker sees its own + # family's 5 requests + 5 replies and nothing of the other's icmp = _matches(marker_ws, "m-icmp") assert len(icmp) == 10, len(icmp) # 5 requests (tx) + 5 replies (rx) + icmp6 = _matches(marker_ws, "m-icmp6") + assert len(icmp6) == 10, len(icmp6) assert not _matches(marker_ws, "m-tcp"), "a non-matching BPF must stay silent" assert len(_matches(marker_ws, "m-tx")) == 5, "the tx direction sees requests only" + assert len(_matches(marker_ws, "m-tx6")) == 5, "the v6 tx direction sees requests only" for event in icmp: assert event["node_id"] == n1_id, event @@ -184,38 +228,61 @@ def test_marker_kernel_datapath(): assert event["tag"] == TAG, event assert event["ts"] > 0, event assert event["len"] == PING_FRAME_LEN, event + for event in icmp6: + assert event["node_id"] == n1_id, event + assert event["link_id"] == lid, event + assert event["tag"] == TAG6, event + assert event["ts"] > 0, event + assert event["len"] == PING_FRAME_LEN6, event assert {e["dir"] for e in icmp} == {"tx", "rx"}, [e["dir"] for e in icmp] + assert {e["dir"] for e in icmp6} == {"tx", "rx"}, [e["dir"] for e in icmp6] assert {e["dir"] for e in _matches(marker_ws, "m-tx")} == {"tx"} + assert {e["dir"] for e in _matches(marker_ws, "m-tx6")} == {"tx"} # The dedicated channel carries matches; the main project stream must # not (and must still be alive — its link.updated proves the socket). assert main_ws.messages("marker.match") == [], main_ws.messages("marker.match")[:3] assert main_ws.messages("link.updated"), "the main channel is not receiving at all" - # Host pcap: exactly the matched frames, off the anchor + # Host pcaps: exactly the matched frames, off the anchor pcap = os.path.join(markers_dir, f"{n1_id}_{lid}_m-icmp.pcap") + pcap6 = os.path.join(markers_dir, f"{n1_id}_{lid}_m-icmp6.pcap") assert os.path.exists(pcap), os.listdir(markers_dir) + assert os.path.exists(pcap6), os.listdir(markers_dir) count, ethertypes = harness.pcap_records(pcap) assert count == 10, count assert set(ethertypes) == {"0800"}, ethertypes + count6, ethertypes6 = harness.pcap_records(pcap6) + assert count6 == 10, count6 + assert set(ethertypes6) == {"86dd"}, ethertypes6 # pause: this marker's signals stop, its pcap survives, and the # sibling marker's matches keep flowing (the channel is alive — - # silence is the pause, not a dead stream) + # silence is the pause, not a dead stream). Both families' streams + # run through the paused window: the count stays frozen for either. print(".. pausing m-icmp: its signals stop while the siblings keep firing") compute.call("PUT", f"/projects/{pid}/links/{lid}/markers/m-icmp", {"enabled": False}) harness.docker_ping(daemon, c1, R2_IP, count=3) - harness.wait_until(lambda: len(_matches(marker_ws, "m-tx")) >= 8, timeout=10) + harness.docker_ping(daemon, c1, R2_IP6, count=3) + harness.wait_until( + lambda: len(_matches(marker_ws, "m-tx")) >= 8 and len(_matches(marker_ws, "m-icmp6")) >= 16, timeout=10 + ) assert len(_matches(marker_ws, "m-icmp")) == 10, "a paused marker must not signal" + assert len(_matches(marker_ws, "m-icmp6")) == 16, "the 3 v6 pings must reach the v6 marker" assert os.path.exists(pcap) # resume: signals come back, pcap keeps appending print(".. resuming m-icmp") compute.call("PUT", f"/projects/{pid}/links/{lid}/markers/m-icmp", {"enabled": True}) harness.docker_ping(daemon, c1, R2_IP, count=2) - harness.wait_until(lambda: len(_matches(marker_ws, "m-icmp")) >= 14, timeout=10) + harness.docker_ping(daemon, c1, R2_IP6, count=2) + harness.wait_until( + lambda: len(_matches(marker_ws, "m-icmp")) >= 14 and len(_matches(marker_ws, "m-icmp6")) >= 20, timeout=10 + ) assert len(_matches(marker_ws, "m-icmp")) == 14 + assert len(_matches(marker_ws, "m-icmp6")) == 20 assert harness.pcap_records(pcap)[0] == 14 + assert harness.pcap_records(pcap6)[0] == 20 if shutil.which("sharkd") is None: print(".. sharkd is not installed: skipping the tag replay section") @@ -261,16 +328,66 @@ def test_marker_kernel_datapath(): status, gate = _raw(compute, f"/projects/{pid}/markers/tags/{TAG}/replay/range") assert status == 409, (status, gate) - # delete: the pcap goes with the marker and the signals stop - print(".. deleting m-icmp: pcap and signals go with it") + # the same replay over the v6 tag: identical mechanics, v6 decode + print(f".. pausing m-icmp6 and replaying tag {TAG6}") + compute.call("PUT", f"/projects/{pid}/links/{lid}/markers/m-icmp6", {"enabled": False}) + status, timeline6 = _raw(compute, f"/projects/{pid}/markers/tags/{TAG6}/replay/range") + assert status == 200, timeline6 + assert timeline6["tag"] == TAG6 + assert timeline6["frame_count"] == 20, timeline6["frame_count"] + assert timeline6["start"] and timeline6["end"], timeline6 + assert timeline6["sources"] == [ + { + "node_id": n1_id, + "link_id": lid, + "marker": "m-icmp6", + "data_link_type": "DLT_EN10MB", + "count": 20, + } + ], timeline6["sources"] + first6 = timeline6["frames"][0] + assert first6["src"] == R1_IP6 and first6["dst"] == R2_IP6, first6 + assert "ICMPv6" in (first6["proto"] or ""), first6 + + # display filter: echo requests only (5 + 3 + 2 of the 20) + query = urllib.parse.urlencode({"filter": "icmpv6.type == 128"}) + status, filtered6 = _raw(compute, f"/projects/{pid}/markers/tags/{TAG6}/replay/range?{query}") + assert status == 200, filtered6 + assert filtered6["frame_count"] == 10, filtered6["frame_count"] + # unknown link: an empty stream, same shape as a zero-match filter + query = urllib.parse.urlencode({"link": "00000000-0000-0000-0000-000000000000"}) + status, empty6 = _raw(compute, f"/projects/{pid}/markers/tags/{TAG6}/replay/range?{query}") + assert status == 200 and empty6["frame_count"] == 0 and empty6["frames"] == [], empty6 + # window endpoint: the whole capture inside one window + query = urllib.parse.urlencode({"ts": timeline6["start"], "window_ms": 120000, "limit": 1000}) + status, window6 = _raw(compute, f"/projects/{pid}/markers/tags/{TAG6}/replay/frames?{query}") + assert status == 200 and len(window6["frames"]) == 20, window6 + + # the gate: replay refuses while the tag is still capturing + compute.call("PUT", f"/projects/{pid}/links/{lid}/markers/m-icmp6", {"enabled": True}) + status, gate6 = _raw(compute, f"/projects/{pid}/markers/tags/{TAG6}/replay/range") + assert status == 409, (status, gate6) + + # delete: the pcaps go with their markers and the signals stop + print(".. deleting m-icmp and m-icmp6: pcaps and signals go with them") compute.call("DELETE", f"/projects/{pid}/links/{lid}/markers/m-icmp") assert harness.wait_until(lambda: not os.path.exists(pcap), timeout=10), pcap + compute.call("DELETE", f"/projects/{pid}/links/{lid}/markers/m-icmp6") + assert harness.wait_until(lambda: not os.path.exists(pcap6), timeout=10), pcap6 harness.docker_ping(daemon, c1, R2_IP, count=2) - harness.wait_until(lambda: len(_matches(marker_ws, "m-tx")) >= 12, timeout=10) + harness.docker_ping(daemon, c1, R2_IP6, count=2) + # both still-attached directioned siblings prove the streams crossed + # and their events were delivered before the deleted markers are + # asserted frozen + harness.wait_until( + lambda: len(_matches(marker_ws, "m-tx")) >= 12 and len(_matches(marker_ws, "m-tx6")) >= 12, timeout=10 + ) assert len(_matches(marker_ws, "m-icmp")) == 14, "a deleted marker must not signal" + assert len(_matches(marker_ws, "m-icmp6")) == 20, "a deleted marker must not signal" compute.call("DELETE", f"/projects/{pid}/links/{lid}/markers/m-tcp") compute.call("DELETE", f"/projects/{pid}/links/{lid}/markers/m-tx") + compute.call("DELETE", f"/projects/{pid}/links/{lid}/markers/m-tx6") assert not os.listdir(markers_dir), os.listdir(markers_dir) except BaseException: harness.release(server, pid, failed=True) diff --git a/tests/e2e/test_qemu_kernel_datapath.py b/tests/e2e/test_qemu_kernel_datapath.py index 607b4387d..b3c18296b 100644 --- a/tests/e2e/test_qemu_kernel_datapath.py +++ b/tests/e2e/test_qemu_kernel_datapath.py @@ -39,6 +39,9 @@ relay attaches the anchor itself as an AF_PACKET endpoint, so it stays up and carrying while the link rides the uBridge relay. This differs from IOU's relay, where the fabric socket carries the traffic and the anchors sit idle: QEMU has no other leg, the TAP is the netdev either way. + +Every checkpoint runs dual-stack: the IOSv guests carry a ULA v6 address +next to their v4 one and each checkpoint pings both families. """ import re @@ -61,6 +64,10 @@ BOOT_TIMEOUT = 480 IMAGE_PREFERENCE = ("vios-adventerprisek9-m.spa.159-3.m12.qcow2",) GUEST_ETH_IF = "GigabitEthernet0/0" +# the dual-stack half: a ULA mirroring 10.1.1.x +R1_IP6 = "fd00:1:1::1" +R2_IP6 = "fd00:1:1::2" + def _pick_image(server): images = server.compute.qemu_images() @@ -154,13 +161,17 @@ def test_qemu_kernel_datapath(): harness.assert_forwarding(a1) harness.assert_forwarding(a2) - harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if=GUEST_ETH_IF) - harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if=GUEST_ETH_IF) + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if=GUEST_ETH_IF, eth_ipv6=R1_IP6) + harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if=GUEST_ETH_IF, eth_ipv6=R2_IP6) out = c1.run("show ip int brief") assert re.search(r"GigabitEthernet0/0\s+10\.1\.1\.1\s+\S+\s+\S+\s+up\s+up", out), out + out = c1.run("show ipv6 interface brief") + assert R1_IP6.upper() in out.upper(), out # IOS prints v6 upper-case print(".. both routers configured, pinging through the anchor TAPs") baseline = harness.wait_ping(c1, "10.1.1.2") assert baseline["success"] == 100, baseline["raw"] + baseline6 = harness.wait_ping(c1, R2_IP6) + assert baseline6["success"] == 100, baseline6["raw"] # L2-anchor spec §E.2: with the guests shut the anchors (QEMU's own # netdevs) and the per-link bridge stay silent for 5 s. @@ -171,6 +182,7 @@ def test_qemu_kernel_datapath(): for console in (c1, c2): _eth_shutdown(console, shut=False) assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100 else: print(".. uBridge without link l2only: skipping the §E.2 assertion") @@ -184,10 +196,15 @@ def test_qemu_kernel_datapath(): delayed = harness.wait_ping(c1, "10.1.1.2") assert delayed["success"] == 100, delayed["raw"] assert delayed["avg"] >= 150, (baseline, delayed) + delayed6 = harness.wait_ping(c1, R2_IP6) + assert delayed6["success"] == 100, delayed6["raw"] + assert delayed6["avg"] >= 150, (baseline6, delayed6) compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}}) assert "netem" not in harness.qdiscs(a1), harness.qdiscs(a1) fast = harness.wait_ping(c1, "10.1.1.2") assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, fast + fast6 = harness.wait_ping(c1, R2_IP6) + assert fast6["success"] == 100 and fast6["avg"] < harness.FAST_RTT_MS, fast6 # suspend: anchor admin-down — the e1000 loses carrier, the bridge # port is disabled and nothing crosses; resume restores @@ -195,19 +212,24 @@ def test_qemu_kernel_datapath(): assert not harness.tap_up(a1) dead = harness.ping(c1, "10.1.1.2", repeat=3) assert dead["success"] == 0, dead["raw"] + dead6 = harness.ping(c1, R2_IP6, repeat=3) + assert dead6["success"] == 0, dead6["raw"] compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": False}) assert harness.tap_up(a1) assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100 - # capture: on the anchor, a real pcap + # capture: on the anchor, a real pcap — both families inside the window capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"}) harness.ping(c1, "10.1.1.2") + harness.ping(c1, R2_IP6) time.sleep(1) compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop") path = capture["capture_file_path"] count, ethertypes = harness.pcap_records(path) - assert count >= 4, f"{path}: {count} records" + assert count >= 8, f"{path}: {count} records" assert "0800" in ethertypes, (path, ethertypes) + assert "86dd" in ethertypes, (path, ethertypes) # delete / re-create the kernel link: the anchor leaves the bridge # but survives (the running VM holds it open — this is QEMU's own @@ -220,6 +242,7 @@ def test_qemu_kernel_datapath(): bridge = harness.link_bridge_name(lid) assert link["kernel_datapath"] is True, link assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100 # node stop: QEMU exits and uBridge deletes every anchor of the # node together with the orphan bridges (a stopped node must not @@ -240,8 +263,9 @@ def test_qemu_kernel_datapath(): c1 = _boot(server, pid, n1_id) assert harness.tap_exists(a1) assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge) - harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if=GUEST_ETH_IF) + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if=GUEST_ETH_IF, eth_ipv6=R1_IP6) assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100 c1.close() c2.close() @@ -307,10 +331,12 @@ def test_qemu_relay_control(): # the L2 hardening is a creation-time property, not a datapath # one: relay anchors are pure L2 too harness.assert_pure_l2(a1) - harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if=GUEST_ETH_IF) - harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if=GUEST_ETH_IF) + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if=GUEST_ETH_IF, eth_ipv6=R1_IP6) + harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_if=GUEST_ETH_IF, eth_ipv6=R2_IP6) relay_ping = harness.wait_ping(c1, "10.1.1.2") assert relay_ping["success"] == 100, relay_ping["raw"] + relay_ping6 = harness.wait_ping(c1, R2_IP6) + assert relay_ping6["success"] == 100, relay_ping6["raw"] c1.close() c2.close()