Merge branch 'feat/e2e-ipv6-dualstack' into fix/duplicate-device-configs-merge

This commit is contained in:
YueGuobin 2026-10-07 13:20:22 +08:00
commit 980254b9e6
No known key found for this signature in database
11 changed files with 809 additions and 105 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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:

View File

@ -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)

View File

@ -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()