diff --git a/test/case/ietf_interfaces/vlan_ping/Readme.adoc b/test/case/ietf_interfaces/vlan_ping/Readme.adoc index 75cdde9d..b976123c 100644 --- a/test/case/ietf_interfaces/vlan_ping/Readme.adoc +++ b/test/case/ietf_interfaces/vlan_ping/Readme.adoc @@ -16,10 +16,12 @@ endif::testgroup[] endif::topdoc[] ==== Test sequence . Set up topology and attach to target DUT +. Set up VLAN interface on host:data with IP 10.0.0.1 . Configure VLAN 10 interface on target:data with IP 10.0.0.2 -. Waiting for links to come up -. Ping 10.0.0.2 from VLAN 10 on host:data with IP 10.0.0.1 -. Remove VLAN interface from target:data, and test again (should not be able to ping) +. Wait for links to come up +. Verify that host:data can reach 10.0.0.2 +. Remove VLAN interface from target:data +. Verify that host:data can no longer reach 10.0.0.2 <<< diff --git a/test/case/ietf_interfaces/vlan_ping/test.py b/test/case/ietf_interfaces/vlan_ping/test.py index 8639f332..db2060b2 100755 --- a/test/case/ietf_interfaces/vlan_ping/test.py +++ b/test/case/ietf_interfaces/vlan_ping/test.py @@ -10,31 +10,21 @@ from infamy import until -def test_ping(hport, should_pass): - with infamy.IsolatedMacVlan(hport) as ns: - try: - ns.runsh(""" - set -ex - ip link set iface up - ip link add dev vlan10 link iface up type vlan id 10 - ip addr add 10.0.0.1/24 dev vlan10 - """) - - if should_pass: - ns.must_reach("10.0.0.2") - else: - ns.must_not_reach("10.0.0.2") - - except Exception as e: - print(f"An error occurred during the VLAN setup or ping test: {e}") - raise - with infamy.Test() as test: with test.step("Set up topology and attach to target DUT"): env = infamy.Env() target = env.attach("target", "mgmt") _, tport = env.ltop.xlate("target", "data") + with test.step("Set up VLAN interface on host:data with IP 10.0.0.1"): + _, hport = env.ltop.xlate("host", "data") + datanet = infamy.IsolatedMacVlan(hport).start() + datanet.runsh(""" + set -ex + ip link set iface up + ip link add dev vlan10 link iface up type vlan id 10 + ip addr add 10.0.0.1/24 dev vlan10 + """) with test.step("Configure VLAN 10 interface on target:data with IP 10.0.0.2"): target.put_config_dict("ietf-interfaces", { @@ -64,16 +54,18 @@ def test_ping(hport, should_pass): } }) - with test.step("Waiting for links to come up"): + with test.step("Wait for links to come up"): until(lambda: iface.get_param(target, tport, "oper-status") == "up") - with test.step("Ping 10.0.0.2 from VLAN 10 on host:data with IP 10.0.0.1"): + with test.step("Verify that host:data can reach 10.0.0.2"): _, hport = env.ltop.xlate("host", "data") - test_ping(hport,True) + datanet.must_reach("10.0.0.2") - with test.step("Remove VLAN interface from target:data, and test again (should not be able to ping)"): + with test.step("Remove VLAN interface from target:data"): target.delete_xpath(f"/ietf-interfaces:interfaces/interface[name='{tport}.10']") + + with test.step("Verify that host:data can no longer reach 10.0.0.2"): _, hport = env.ltop.xlate("host", "data") - test_ping(hport,False) + datanet.must_not_reach("10.0.0.2") test.succeed() diff --git a/test/case/ietf_routing/Readme.adoc b/test/case/ietf_routing/Readme.adoc index 539fcdaf..99234e34 100644 --- a/test/case/ietf_routing/Readme.adoc +++ b/test/case/ietf_routing/Readme.adoc @@ -11,3 +11,5 @@ include::ospf_basic/Readme.adoc[] include::ospf_unnumbered_interface/Readme.adoc[] include::ospf_multiarea/Readme.adoc[] + +include::ospf_bfd/Readme.adoc[] diff --git a/test/case/ietf_routing/ietf_routing.yaml b/test/case/ietf_routing/ietf_routing.yaml index 633f3a11..45228205 100644 --- a/test/case/ietf_routing/ietf_routing.yaml +++ b/test/case/ietf_routing/ietf_routing.yaml @@ -10,3 +10,6 @@ - name: ospf_multiarea case: ospf_multiarea/test.py + +- name: ospf_bfd + case: ospf_bfd/test.py diff --git a/test/case/ietf_routing/ospf_bfd/Readme.adoc b/test/case/ietf_routing/ospf_bfd/Readme.adoc new file mode 100644 index 00000000..ae2b34d5 --- /dev/null +++ b/test/case/ietf_routing/ospf_bfd/Readme.adoc @@ -0,0 +1,35 @@ +=== OSPF BFD +==== Description +Verify that a router running OSPF, with Bidirectional Forwarding +Detection (BFD) enabled, will detect link faults even when the +physical layer is still operational. + +This can typically happen when one logical link, from OSPF's +perspective, is made up of multiple physical links containing media +converters without link fault forwarding. + +==== Topology +ifdef::topdoc[] +image::../../test/case/ietf_routing/ospf_bfd/topology.svg[OSPF BFD topology] +endif::topdoc[] +ifndef::topdoc[] +ifdef::testgroup[] +image::ospf_bfd/topology.svg[OSPF BFD topology] +endif::testgroup[] +ifndef::testgroup[] +image::topology.svg[OSPF BFD topology] +endif::testgroup[] +endif::topdoc[] +==== Test sequence +. Set up topology and attach to target DUTs +. Setup TPMR between R1fast and R2fast +. Configure R1 and R2 +. Setup IP addresses and default routes on h1 and h2 +. Wait for R1 and R2 to peer +. Verify connectivity from PC:src to PC:dst via fast link +. Disable forwarding between R1fast and R2fast to trigger fail-over +. Verify connectivity from PC:src to PC:dst via slow link + + +<<< + diff --git a/test/case/ietf_routing/ospf_bfd/test.py b/test/case/ietf_routing/ospf_bfd/test.py new file mode 100755 index 00000000..31740f4c --- /dev/null +++ b/test/case/ietf_routing/ospf_bfd/test.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 + +""" +OSPF BFD + +Verify that a router running OSPF, with Bidirectional Forwarding +Detection (BFD) enabled, will detect link faults even when the +physical layer is still operational. + +This can typically happen when one logical link, from OSPF's +perspective, is made up of multiple physical links containing media +converters without link fault forwarding. +""" + +import time + +import infamy +import infamy.route as route +from infamy.netns import TPMR +from infamy.util import until, parallel + +def config(target, params): + name = params["name"] + dif, fif, sif = \ + params["link"]["data"], \ + params["link"]["fast"], \ + params["link"]["slow"] + rid, daddr, faddr, saddr = \ + params["addr"]["rid"], \ + params["addr"]["data"], \ + params["addr"]["fast"], \ + params["addr"]["slow"] + + def ifconfig(name, addr, plen): + return { + "name": name, + "enabled": True, + "ipv4": { + "forwarding": True, + "address": [{ + "ip": addr, + "prefix-length": plen, + }]} + } + + target.put_config_dict("ietf-interfaces", { + "interfaces": { + "interface": [ + ifconfig("lo", rid, 32), + + ifconfig(dif, daddr, 24), + ifconfig(fif, faddr, 30), + ifconfig(sif, saddr, 30), + ] + } + }) + + target.put_config_dict("ietf-system", { + "system": { + "hostname": name, + } + }) + + target.put_config_dict("ietf-routing", { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": "infix-routing:ospfv2", + "name": "default", + "ospf": { + "areas": { + "area": [{ + "area-id": "0.0.0.0", + "interfaces": + { + "interface": [{ + "bfd": { + "enabled": True + }, + "name": fif, + "hello-interval": 1, + "dead-interval": 10, + "cost": 100, + }, + { + "bfd": { + "enabled": True + }, + "name": sif, + "hello-interval": 1, + "dead-interval": 10, + "cost": 200, + }, { + "name": dif, + "passive": True, + }] + }, + }] + } + } + }] + } + } + }) + +with infamy.Test() as test: + with test.step("Set up topology and attach to target DUTs"): + env = infamy.Env() + R1 = env.attach("R1", "mgmt") + R2 = env.attach("R2", "mgmt") + + with test.step("Setup TPMR between R1fast and R2fast"): + breaker = TPMR(env.ltop.xlate("PC", "R1fast")[1], + env.ltop.xlate("PC", "R2fast")[1]).start() + + with test.step("Configure R1 and R2"): + r1cfg = { + "name": "R1", + "addr": { + "rid": "192.168.1.1", + + "data": "192.168.10.1", + "fast": "192.168.100.1", + "slow": "192.168.200.1", + }, + "link": { + "data": env.ltop.xlate("R1", "h1")[1], + "fast": env.ltop.xlate("R1", "fast")[1], + "slow": env.ltop.xlate("R1", "slow")[1], + } + } + r2cfg = { + "name": "R2", + "addr": { + "rid": "192.168.1.2", + + "data": "192.168.20.1", + "fast": "192.168.100.2", + "slow": "192.168.200.2", + }, + "link": { + "data": env.ltop.xlate("R2", "h2")[1], + "fast": env.ltop.xlate("R2", "fast")[1], + "slow": env.ltop.xlate("R2", "slow")[1], + } + } + + parallel(config(R1, r1cfg), config(R2, r2cfg)) + + with test.step("Setup IP addresses and default routes on h1 and h2"): + _, h1 = env.ltop.xlate("PC", "h1") + _, h2 = env.ltop.xlate("PC", "h2") + + h1net = infamy.IsolatedMacVlan(h1).start() + h1net.addip("192.168.10.2") + h1net.addroute("default", "192.168.10.1") + + h2net = infamy.IsolatedMacVlan(h2).start() + h2net.addip("192.168.20.2") + h2net.addroute("default", "192.168.20.1") + + with test.step("Wait for R1 and R2 to peer"): + print("Waiting for R1 and R2 to peer") + until(lambda: route.ipv4_route_exist(R1, "192.168.20.0/24", proto="ietf-ospf:ospfv2"), attempts=200) + until(lambda: route.ipv4_route_exist(R2, "192.168.10.0/24", proto="ietf-ospf:ospfv2"), attempts=200) + + with test.step("Verify connectivity from PC:src to PC:dst via fast link"): + h1net.must_reach("192.168.20.2") + hops = [row[1] for row in h1net.traceroute("192.168.20.2")] + assert "192.168.100.2" in hops, f"Path to h2 ({repr(hops)}), does not use fast link" + + with test.step("Disable forwarding between R1fast and R2fast to trigger fail-over"): + breaker.block() + print("Give BFD some time to detect the bad link, " + + "but not enough for the OSPF dead interval expire") + time.sleep(1) + + with test.step("Verify connectivity from PC:src to PC:dst via slow link"): + h1net.must_reach("192.168.20.2") + hops = [row[1] for row in h1net.traceroute("192.168.20.2")] + assert "192.168.200.2" in hops, f"Path to h2 ({repr(hops)}), does not use slow link" + + test.succeed() diff --git a/test/case/ietf_routing/ospf_bfd/topology.dot b/test/case/ietf_routing/ospf_bfd/topology.dot new file mode 100644 index 00000000..f6f59985 --- /dev/null +++ b/test/case/ietf_routing/ospf_bfd/topology.dot @@ -0,0 +1,39 @@ +graph "ospf-bfd" { + layout="neato"; + overlap="false"; + esep="+20"; + size=10 + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + R1 [ + label=" { { R1 | slow } | { mgmt |

h1 | fast } }", + pos="0,6!", + + kind="infix", + ]; + R2 [ + label="{ { slow | R2 } | { fast |

h2 | mgmt } }", + pos="18,6!", + + kind="infix", + ]; + + PC [ + label="{ { R1mgmt |

h1 | R1fast | R2fast |

h2 | R2mgmt } | PC }", + pos="9,0!", + kind="controller", + ]; + + PC:R1mgmt -- R1:mgmt [kind=mgmt, color="lightgray"] + PC:R2mgmt -- R2:mgmt [kind=mgmt, color="lightgray"] + + PC:h1 -- R1:h1 + PC:h2 -- R2:h2 + + R1:fast -- PC:R1fast [color="lightgreen", taillabel="Cost: 100"] + R2:fast -- PC:R2fast [color="lightgreen"] + + R1:slow -- R2:slow [color="crimson", taillabel="Cost: 200"] +} diff --git a/test/case/ietf_routing/ospf_bfd/topology.svg b/test/case/ietf_routing/ospf_bfd/topology.svg new file mode 100644 index 00000000..ca1ec0a6 --- /dev/null +++ b/test/case/ietf_routing/ospf_bfd/topology.svg @@ -0,0 +1,95 @@ + + + + + + +ospf-bfd + + + +R1 + +R1 + +slow + +mgmt + +h1 + +fast + + + +R2 + +slow + +R2 + +fast + +h2 + +mgmt + + + +R1:slow--R2:slow + +Cost: 200 + + + +PC + +R1mgmt + +h1 + +R1fast + +R2fast + +h2 + +R2mgmt + +PC + + + +R1:fast--PC:R1fast + +Cost: 100 + + + +R2:fast--PC:R2fast + + + + +PC:R1mgmt--R1:mgmt + + + + +PC:h1--R1:h1 + + + + +PC:R2mgmt--R2:mgmt + + + + +PC:h2--R2:h2 + + + + diff --git a/test/infamy/netns.py b/test/infamy/netns.py index 52e9590e..3d66ffe1 100644 --- a/test/infamy/netns.py +++ b/test/infamy/netns.py @@ -1,4 +1,5 @@ import ctypes +import json import multiprocessing import os import random @@ -18,31 +19,58 @@ def setns(fd, nstype): __NR_setns = 308 __libc.syscall(__NR_setns, fd, nstype) -class IsolatedMacVlan: - """Create an isolated interface on top of a PC interface.""" - def __init__(self, parent, ifname="iface", lo=True): +class IsolatedMacVlans: + """A network namespace containing a multiple MACVLANs + + Stacks a MACVLAN on top of each specificed controller interface, + and moves those interfaces to a separate namespace, isolating it + from all others. + + NOTE: For the simple case when only one interface needs to be + mapped, see IsolatedMacVlan below. + + Example: + + netns = IsolatedMacVlans({ "eth2": "a", "eth3": "b" }) + + netns: + .--------. + | a b | (MACVLANs) + '-+----+-' + | | + eth0 eth1 eth2 eth3 + + """ + + Instances = [] + def Cleanup(): + for ns in list(IsolatedMacVlans.Instances): + ns.stop() + + def __init__(self, ifmap, lo=True): self.sleeper = None - self.parent, self.ifname, self.lo = parent, ifname, lo + self.ifmap, self.lo = ifmap, lo self.ping_timeout = env.ENV.attr("ping_timeout", 5) - def __enter__(self): + def start(self): self.sleeper = subprocess.Popen(["unshare", "-r", "-n", "sh", "-c", "echo && exec sleep infinity"], stdout=subprocess.PIPE) self.sleeper.stdout.readline() try: - subprocess.run(["ip", "link", "add", - "dev", self.ifname, - "link", self.parent, - "address", self._stable_mac(), - "netns", str(self.sleeper.pid), - "type", "macvlan"], check=True) - self.runsh(f""" - while ! ip link show dev {self.ifname}; do - sleep 0.1 - done - """) + for parent, ifname in self.ifmap.items(): + subprocess.run(["ip", "link", "add", + "dev", ifname, + "link", parent, + "address", self._stable_mac(parent), + "netns", str(self.sleeper.pid), + "type", "macvlan", "mode", "passthru"], check=True) + self.runsh(f""" + while ! ip link show dev {ifname}; do + sleep 0.1 + done + """) except Exception as e: self.__exit__(None, None, None) raise e @@ -54,14 +82,43 @@ def __enter__(self): self.__exit__(None, None, None) raise e + self.Instances.append(self) return self - def __exit__(self, val, typ, tb): + def stop(self): self.sleeper.kill() self.sleeper.wait() - time.sleep(0.5) - def _stable_mac(self): + for n in range(100): + promisc = False + for parent in self.ifmap.keys(): + iplink = subprocess.run(f"ip -d -j link show dev {parent}".split(), + stdout=subprocess.PIPE, check=True) + link = json.loads(iplink.stdout)[0] + if link["promiscuity"]: + # Use promisc as a substitute for an indicator + # of whether the kernel has actually removed + # the passthru MACVLAN yet or not + promisc = True + break + + if not promisc: + break + + time.sleep(.1) + else: + raise TimeoutError("Lingering MACVLAN") + + if self in self.Instances: + self.Instances.remove(self) + + def __enter__(self): + return self.start() + + def __exit__(self, val, typ, tb): + return self.stop() + + def _stable_mac(self, parent): """Generate address for MACVLAN By default, the kernel will assign a random address. This @@ -75,7 +132,7 @@ def _stable_mac(self): the resource exhaustion issue. """ - random.seed(self.parent) + random.seed(parent) a = list(random.randbytes(6)) a[0] |= 0x02 a[0] &= ~0x01 @@ -146,13 +203,13 @@ def addroute(self, subnet, nexthop, proto="ipv4", prefix_length=""): ip -{p} route add {subnet}{prefix_length} via {nexthop} """, check=True) - def addip(self, addr, prefix_length=24, proto="ipv4"): + def addip(self, ifname, addr, prefix_length=24, proto="ipv4"): p=proto[3] self.runsh(f""" set -ex ip link set iface up - ip -{p} addr add {addr}/{prefix_length} dev iface + ip -{p} addr add {addr}/{prefix_length} dev {ifname} """, check=True) @@ -188,8 +245,7 @@ def must_not_reach(self, *args, **kwargs): raise Exception(res) - def must_receive(self, expr, timeout=None, ifname=None, must=True): - ifname = ifname if ifname else self.ifname + def must_receive(self, expr, ifname, timeout=None, must=True): timeout = timeout if timeout else self.ping_timeout tshark = self.run(["tshark", "-nl", f"-i{ifname}", @@ -207,10 +263,43 @@ def must_receive(self, expr, timeout=None, ifname=None, must=True): def must_not_receive(self, *args, **kwargs): self.must_receive(*args, **kwargs, must=False) - def pcap(self, expr, ifname=None): - ifname = ifname if ifname else self.ifname + def pcap(self, expr, ifname): return Pcap(self, ifname, expr) +class IsolatedMacVlan(IsolatedMacVlans): + """A network namespace containing a single MACVLAN + + Stacks a MACVLAN on top of an interface on the controller, and + moves that interface to a separate namespace, isolating it from + all other interfaces. + + Example: + + netns = IsolatedMacVlan("eth3") + + netns: + .-------. + | iface | (MACVLAN) + '---+---' + | + eth0 eth1 eth2 eth3 + + """ + def __init__(self, parent, ifname="iface", lo=True): + self._ifname = ifname + return super().__init__(ifmap={ parent: ifname }, lo=lo) + + def addip(self, addr, prefix_length=24, proto="ipv4"): + return super().addip(ifname=self._ifname, addr=addr, prefix_length=prefix_length, proto=proto) + + def must_receive(self, expr, timeout=None, ifname=None, must=True): + ifname = ifname if ifname else self._ifname + return super().must_receive(expr=expr, ifname=ifname, timeout=timeout, must=must) + + def pcap(self, expr, ifname=None): + ifname = ifname if ifname else self._ifname + return super().pcap(expr=expr, ifname=ifname) + class Pcap: def __init__(self, netns, ifname, expr): self.netns, self.ifname, self.expr = netns, ifname, expr @@ -253,7 +342,6 @@ def stop(self, sleep=3): # terminating the capture. time.sleep(sleep) - self.proc.send_signal(signal.SIGUSR2) self.proc.terminate() try: _, stderr = self.proc.communicate(5) @@ -272,3 +360,52 @@ def tcpdump(self, args=""): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=True) return tcpdump.stdout + +class TPMR(IsolatedMacVlans): + """Two-Port MAC Relay + + Creates a network namespace containing two controller interfaces + (`a` and `b`). By default, tc rules are setup to copy all frames + ingressing on `a` to egress on `b`, and vice versa. + + These rules can be removed and reinserted dynamically using the + `block()` and `forward()` methods, respectively. + + This is useful to verify the correctness of fail-over behavior in + various protocols. See ospf_bfd for a usage example. + """ + + def __init__(self, a, b): + super().__init__(ifmap={ a: "a", b: "b" }, lo=False) + + def start(self, forward=True): + ret = super().start() + + for dev in ("a", "b"): + self.run(f"ip link set dev {dev} promisc on up".split()) + self.run(f"tc qdisc add dev {dev} clsact".split()) + + if forward: + self.forward() + + return ret + + def _clear_ingress(self, iface): + return self.run(f"tc filter del dev {iface} ingress".split()) + + def _add_redir(self, frm, to): + cmd = \ + "tc filter add dev".split() \ + + [frm] \ + + "ingress matchall action mirred egress redirect dev".split() \ + + [to] + return self.run(cmd) + + def forward(self): + for iface in ("a", "b"): + self._clear_ingress(iface) + self._add_redir(iface, "a" if iface == "b" else "b") + + def block(self): + for iface in ("a", "b"): + self._clear_ingress(iface) diff --git a/test/infamy/tap.py b/test/infamy/tap.py index dc2e462e..42a2ed61 100644 --- a/test/infamy/tap.py +++ b/test/infamy/tap.py @@ -4,6 +4,8 @@ import sys import traceback +import infamy.netns + class Test: def __init__(self, output=sys.stdout): self.out = output @@ -24,6 +26,8 @@ def __exit__(self, _, e, __): self.out.write(f"# Exiting ({now})\n") self.out.flush() + infamy.netns.IsolatedMacVlans.Cleanup() + if not e: self._not_ok("Missing explicit test result\n") else: