Files
infix/test/infamy/netns.py
T
Tobias Waldekranz 68b1632f19 test: netns: Add packet capture helper
This tries to improve on the existing sniffer in a couple of ways:

- In addition to being usable as a context manager, also allow
  explicitly starting and stopping the capture.

- Try to synchronize the start of the capture by snooping stderr and
  wait for tcpdump to start up. This should eliminate the need for
  sleeps before starting to send packets.

- By default, execute a safety sleep when terminating the capture to
  ensure that any straggling packets have had time to arrive at their
  destination.
2024-06-07 10:10:38 +02:00

275 lines
8.7 KiB
Python

import ctypes
import multiprocessing
import os
import random
import signal
import subprocess
import tempfile
import time
from . import env
__libc = ctypes.CDLL(None)
CLONE_NEWUSER = 0x10000000
CLONE_NEWNET = 0x40000000
# TODO: Replace me with os.setns once Python 3.12 is old news
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):
self.sleeper = None
self.parent, self.ifname, self.lo = parent, ifname, lo
self.ping_timeout = env.ENV.attr("ping_timeout", 5)
def __enter__(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
""")
except Exception as e:
self.__exit__(None, None, None)
raise e
if self.lo:
try:
self.run(["ip", "link", "set", "dev", "lo", "up"])
except Exception as e:
self.__exit__(None, None, None)
raise e
return self
def __exit__(self, val, typ, tb):
self.sleeper.kill()
self.sleeper.wait()
time.sleep(0.5)
def _stable_mac(self):
"""Generate address for MACVLAN
By default, the kernel will assign a random address. This
causes issues when the parent interface is an Intel X710 NIC,
which will add those addresses to its FDB, but never remove
them when the interface is deleted.
Work around the issue by generating an address that is pseudo
random, to avoid address conflicts; yet stable across
instantiations for any given parent interface name, to avoid
the resource exhaustion issue.
"""
random.seed(self.parent)
a = list(random.randbytes(6))
a[0] |= 0x02
a[0] &= ~0x01
return \
f"{a[0]:02x}:{a[1]:02x}:{a[2]:02x}:" + \
f"{a[3]:02x}:{a[4]:02x}:{a[5]:02x}"
def _ns_call(self, fn, tx):
pid = self.sleeper.pid
uns = os.open(f"/proc/{pid}/ns/user", os.O_RDONLY)
setns(uns, CLONE_NEWUSER)
os.close(uns)
nns = os.open(f"/proc/{pid}/ns/net", os.O_RDONLY)
setns(nns, CLONE_NEWNET)
os.close(nns)
tx.send(fn())
tx.close()
def call(self, fn):
rx, tx = multiprocessing.Pipe(duplex=False)
proc = multiprocessing.Process(target=self._ns_call, args=(fn, tx))
proc.start()
ret = rx.recv()
rx.close()
proc.join()
return ret
def _mangle_subprocess_args(self, args, kwargs):
if args:
args = list(args)
if type(args[0]) == str:
if "shell" in kwargs and kwargs["shell"]:
args[0] = ["/bin/sh", "-c", args[0]]
kwargs["shell"] = False
else:
args[0] = [args[0]]
if type(args[0]) == list:
args[0] = ["nsenter", "-t", str(self.sleeper.pid),
"-n", "-U", "--preserve-credentials"] + args[0]
return args, kwargs
raise ValueError("Unable mangle subprocess arguments")
def run(self, *args, **kwargs):
args, kwargs = self._mangle_subprocess_args(args, kwargs)
return subprocess.run(*args, **kwargs)
def popen(self, *args, **kwargs):
args, kwargs = self._mangle_subprocess_args(args, kwargs)
return subprocess.Popen(*args, **kwargs)
def runsh(self, script, *args, **kwargs):
return self.run("/bin/sh", text=True, input=script,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, *args, **kwargs)
def addroute(self, subnet, nexthop, proto="ipv4", prefix_length=""):
p=proto[3]
if prefix_length:
prefix_length=f"/{prefix_length}"
self.runsh(f"""
set -ex
ip -{p} route add {subnet}{prefix_length} via {nexthop}
""", check=True)
def addip(self, 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
""", check=True)
def traceroute(self, addr):
res=self.runsh(f"""
set -ex
traceroute -n {addr}
""", check=True)
result=[]
for line in res.stdout.splitlines()[2:]:
l=line.split()
result.append(l)
return result
def ping(self, daddr, id=None, timeout=None):
timeout = timeout if timeout else self.ping_timeout
id = f"-e {id}" if id else ""
ping = f"ping -c1 -w1 {id} {daddr}"
return self.run(["timeout", str(timeout), "/bin/sh"], text=True, check=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
input=f"while :; do {ping} && break; done")
def must_reach(self, *args, **kwargs):
self.ping(*args, **kwargs)
def must_not_reach(self, *args, **kwargs):
try:
res = self.ping(*args, **kwargs)
except subprocess.CalledProcessError as e:
return
raise Exception(res)
def must_receive(self, expr, timeout=None, ifname=None, must=True):
ifname = ifname if ifname else self.ifname
timeout = timeout if timeout else self.ping_timeout
tshark = self.run(["tshark", "-nl", f"-i{ifname}",
f"-aduration:{timeout}", "-c1", expr],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True, check=True)
needle = "1 packet captured" if must else "0 packets captured"
if needle not in tshark.stdout:
raise Exception(tshark)
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
return Pcap(self, ifname, expr)
class Pcap:
def __init__(self, netns, ifname, expr):
self.netns, self.ifname, self.expr = netns, ifname, expr
self.pcap = tempfile.NamedTemporaryFile(suffix=".pcap", delete=False)
self.proc = None
def __del__(self):
self.pcap.close()
os.unlink(self.pcap.name)
def __enter__(self):
self.start()
return self
def __exit__(self, _, __, ___):
self.stop()
def start(self):
assert self.proc == None, "Can't start an already running Pcap"
argv = f"tshark -ln -i {self.ifname} -w {self.pcap.name} {self.expr}".split()
self.proc = self.netns.popen(argv,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True)
while " -- Capture started." not in self.proc.stderr.readline():
pass
print("Capture running")
def stop(self, sleep=3):
assert self.proc, "Can't stop an already stopped Pcap"
if sleep:
# In the common case, stop() will be called right after
# the final packet of whatever we're testing has just been
# sent. Therefore, allow for some time to pass before
# terminating the capture.
time.sleep(sleep)
self.proc.send_signal(signal.SIGUSR2)
self.proc.terminate()
try:
_, stderr = self.proc.communicate(5)
print(stderr)
return
except subprocess.TimeoutExpired:
try:
self.proc.kill()
except OSError:
pass
self.proc.wait()
def tcpdump(self, args=""):
tcpdump = subprocess.run((f"tcpdump -r {self.pcap.name} -n " + args).split(),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, check=True)
return tcpdump.stdout