diff --git a/test/infamy/dhcp.py b/test/infamy/dhcp.py index 61c6ee173..f55b04288 100644 --- a/test/infamy/dhcp.py +++ b/test/infamy/dhcp.py @@ -2,17 +2,17 @@ import os import tempfile as tf import subprocess +from .netns import NetnsService -class Server: +class Server(NetnsService): config_file = '/tmp/udhcpd.conf' leases_file = '/tmp/udhcpd.leases' def __init__(self, netns, start='192.168.0.100', end='192.168.0.110', netmask='255.255.255.0', ip=None, router=None, prefix=None, hostname=None, iface="iface"): - self.process = None - self.netns = netns + super().__init__(netns) self.iface = iface self._create_files(start, end, netmask, ip, router, prefix, hostname) @@ -26,12 +26,6 @@ def __del__(self): except: pass - def __enter__(self): - self.start() - - def __exit__(self, _, __, ___): - self.stop() - def _create_files(self, start, end, netmask, ip, router, prefix, hostname): f = open(self.leases_file, "w") f.close() @@ -56,20 +50,8 @@ def _create_files(self, start, end, netmask, ip, router, prefix, hostname): if hostname: f.write(f"option hostname {hostname}\n") - def get_pid(self): - return self.process.pid - - def start(self): - if not os.path.exists(self.config_file): - raise Exception("Config file does not exist. Please create it first.") - cmd = f"udhcpd -f {self.config_file}" - self.process = self.netns.popen(cmd.split(" ")) - - def stop(self): - if self.process: - self.process.terminate() - self.process.wait() - self.process = None + def argv(self): + return ["udhcpd", "-f", self.config_file] class Client: @@ -101,8 +83,10 @@ def lease(self): return tuple(res.stdout.split()) -class Server6Dnsmasq: +class Server6Dnsmasq(NetnsService): """DHCPv6 server using dnsmasq""" + # Drop the DEVNULL redirect to debug + popen_kwargs = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def __init__(self, netns, start=None, end=None, dns=None, domain=None, iface="iface", address=None): @@ -116,8 +100,7 @@ def __init__(self, netns, start=None, end=None, dns=None, domain=None, domain: DNS search domain iface: Interface to listen on """ - self.process = None - self.netns = netns + super().__init__(netns) self.iface = iface self.config_file = tf.NamedTemporaryFile(mode='w', prefix='dnsmasq6_', suffix='.conf', delete=False) @@ -143,13 +126,6 @@ def __del__(self): except: pass - def __enter__(self): - self.start() - return self - - def __exit__(self, _, __, ___): - self.stop() - def _create_config(self, start, end, dns, domain, address): """Create dnsmasq configuration for DHCPv6""" with self.hosts_file: @@ -191,26 +167,12 @@ def _create_config(self, start, end, dns, domain, address): self.config_file.write("log-debug\n") self.config_file.write("log-dhcp\n") - def start(self): - """Start the DHCPv6 server""" - if not os.path.exists(self.config_path): - raise Exception("Config file does not exist") - - # Drop DEVNULL redirec to debug - cmd = f"dnsmasq --conf-file={self.config_path} --no-daemon" - self.process = self.netns.popen(cmd.split(" "), - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL) - - def stop(self): - """Stop the DHCPv6 server""" - if self.process: - self.process.terminate() - self.process.wait() - self.process = None - -class Server6Dhcpd: + def argv(self): + return ["dnsmasq", f"--conf-file={self.config_path}", "--no-daemon"] + +class Server6Dhcpd(NetnsService): """DHCPv6 server using ISC dhcpd with prefix delegation support""" + popen_kwargs = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def __init__(self, netns, start=None, end=None, prefix=None, prefix_len=64, dns=None, domain=None, iface="iface", address=None, subnet="2001:db8::/48"): @@ -228,8 +190,7 @@ def __init__(self, netns, start=None, end=None, prefix=None, prefix_len=64, address: Server address on the interface (for subnet config) subnet: Subnet declaration (e.g., "2001:db8::/48") """ - self.process = None - self.netns = netns + super().__init__(netns) self.iface = iface self.subnet = subnet @@ -260,13 +221,6 @@ def __del__(self): except: pass - def __enter__(self): - self.start() - return self - - def __exit__(self, _, __, ___): - self.stop() - def _create_config(self, start, end, prefix, prefix_len, dns, domain): """Create ISC dhcpd configuration for DHCPv6 with prefix delegation""" with self.config_file: @@ -331,11 +285,7 @@ def _create_config(self, start, end, prefix, prefix_len, dns, domain): self.config_file.write("}\n") - def start(self): - """Start the DHCPv6 server""" - if not os.path.exists(self.config_path): - raise Exception("Config file does not exist") - + def argv(self): # Debug: show config and interface status # self.netns.popen(f"cat {self.config_path}".split(" ")) # self.netns.popen("ifconfig") @@ -347,7 +297,7 @@ def start(self): # -cf: Config file # -lf: Lease file # -pf: PID file - cmd = [ + return [ "dhcpd", "-6", # IPv6 mode "-f", # Foreground @@ -357,18 +307,3 @@ def start(self): "-pf", self.pid_path, self.iface # Interface to listen on ] - - self.process = self.netns.popen(cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL) - - def stop(self): - """Stop the DHCPv6 server""" - if self.process: - self.process.terminate() - try: - self.process.wait(timeout=5) - except subprocess.TimeoutExpired: - self.process.kill() - self.process.wait() - self.process = None diff --git a/test/infamy/file_server.py b/test/infamy/file_server.py index 266bca58f..fb86d4943 100644 --- a/test/infamy/file_server.py +++ b/test/infamy/file_server.py @@ -1,46 +1,26 @@ """ Basic file server over HTTP """ -import concurrent.futures -import functools -import http.server -import socket from infamy.util import until +from .netns import NetnsService -class FileServer: +class FileServer(NetnsService): """Open web server on (address, port) serving files from directory""" def __init__(self, netns, address, port, directory): + super().__init__(netns) self.address = address self.port = port self.directory = directory - self.netns = netns - self.process = None - self.check_addres = None + def argv(self): + return f"httpd -p {self.address}:{self.port} -f -h {self.directory}".split(" ") - def start(self): - """start HTTP file server""" - cmd = f"httpd -p {self.address}:{self.port} -f -h {self.directory}" - self.process = self.netns.popen(cmd.split(" ")) + def ready(self): if self.address == "[::]": check_address = "::1" elif self.address == "0.0.0.0": check_address = "127.0.0.1" else: - check_address=self.address + check_address = self.address cmd = f"nc -z {check_address} {self.port}".split() until(lambda: self.netns.run(cmd).returncode == 0) - - - def stop(self): - """Stop HTTP file server""" - if self.process: - self.process.terminate() - self.process.wait() - self.process = None - - def __enter__(self): - self.start() - - def __exit__(self, _, __, ___): - self.stop() diff --git a/test/infamy/firewall.py b/test/infamy/firewall.py index 24d0078a2..6966efec6 100644 --- a/test/infamy/firewall.py +++ b/test/infamy/firewall.py @@ -11,11 +11,28 @@ import subprocess import time from typing import Tuple, List +from .netns import NetnsService from .sniffer import Sniffer from .portscanner import PortScanner from .util import until +class DnatTarget(NetnsService): + """One-shot nc server that answers DNAT-TEST-OK on port""" + popen_kwargs = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + def __init__(self, netns, port): + super().__init__(netns) + self.port = port + + def argv(self): + return ["nc", "-l", "-p", str(self.port), "-e", "/bin/echo", "DNAT-TEST-OK"] + + def ready(self): + # Probing the port would use up the only connection nc accepts + time.sleep(1) + + class Firewall: """Specialized utilities for testing firewall functionality""" @@ -203,21 +220,10 @@ def verify_dnat(self, gateway_ip: str, forward_port: int, target_port: int, Tuple of (dnat_working: bool, details: str) """ try: - # Use netcat to simulate a simple service on target port - cmd = f"nc -l -p {target_port} -e /bin/echo 'DNAT-TEST-OK'" - pid = self.dstns.popen(cmd.split(), stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - time.sleep(1) # Give server time to start - # Test connection from source to gateway:forward_port cmd = f"nc -w {timeout} {gateway_ip} {forward_port}" - result = self.srcns.runsh(cmd) - - try: - pid.terminate() - pid.wait(timeout=1) - except: - pid.kill() + with DnatTarget(self.dstns, target_port): + result = self.srcns.runsh(cmd) # Check if we got the expected response if "DNAT-TEST-OK" in result.stdout: diff --git a/test/infamy/multicast.py b/test/infamy/multicast.py index ac36b0b5b..4a8fb4c74 100644 --- a/test/infamy/multicast.py +++ b/test/infamy/multicast.py @@ -1,83 +1,31 @@ -import time -import subprocess import signal -import sys -from scapy.all import Ether, sendp - -class MCastSender: - def __init__(self, netns,group): - self.group = group - self.netns = netns - - def __enter__(self): - cmd = f"msend -I iface -g {self.group}" - arg = cmd.split(" ") - - self.proc = self.netns.popen(arg, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - def __exit__(self, _, __, ___): - if not self.proc: - return False +import subprocess +from .netns import NetnsService - sys.stdout.flush() - self.proc.send_signal(signal.SIGINT) - time.sleep(1) - if not self.proc.poll(): - try: - self.proc.kill() - except OSError: - pass - self.proc.wait() +class _MCastService(NetnsService): + """msend, mreceive and the scapy sender all exit on SIGINT""" + stop_signal = signal.SIGINT + # Nothing reads the output. A pipe would fill up and block the + # process, so discard it. + popen_kwargs = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) -class MCastReceiver: def __init__(self, netns, group): + super().__init__(netns) self.group = group - self.netns = netns - - def __enter__(self): - cmd = f"mreceive -I iface -g {self.group}" - arg = cmd.split(" ") - - self.proc = self.netns.popen(arg, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - def __exit__(self, _, __, ___): - if not self.proc: - return False +class MCastSender(_MCastService): + def argv(self): + return f"msend -I iface -g {self.group}".split(" ") - sys.stdout.flush() - self.proc.send_signal(signal.SIGINT) - time.sleep(3) - if not self.proc.poll(): - print("PROC") - try: - self.proc.kill() - except OSError: - print("ERR") - pass - self.proc.wait() - -class MacMCastSender: - def __init__(self, netns, group): - self.group = group - self.netns = netns +class MCastReceiver(_MCastService): + def argv(self): + return f"mreceive -I iface -g {self.group}".split(" ") - def __enter__(self): +class MacMCastSender(_MCastService): + def argv(self): send_cmd = ( "from scapy.all import sendp, Ether; " f"pkt=Ether(src='aa:bb:cc:dd:ee:ff', dst='{self.group}', type=0xdead); " "sendp(pkt, iface='iface', loop=1, inter=1./10)" ) - self.proc = self.netns.popen(["python3", "-c", send_cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - return self - - def __exit__(self, _, __, ___): - if self.proc: - sys.stdout.flush() - self.proc.send_signal(signal.SIGINT) - time.sleep(1) - if not self.proc.poll(): - try: - self.proc.kill() - except OSError: - pass - self.proc.wait() + return ["python3", "-c", send_cmd] diff --git a/test/infamy/netns.py b/test/infamy/netns.py index c1bb678ee..dd90fe3b5 100644 --- a/test/infamy/netns.py +++ b/test/infamy/netns.py @@ -3,6 +3,7 @@ import multiprocessing import os import random +import signal import subprocess import tempfile import time @@ -345,60 +346,103 @@ 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 - self.pcap = tempfile.NamedTemporaryFile(suffix=".pcap", delete=False) - self.proc = None +class NetnsService: + """A long-running process inside a network namespace - def __del__(self): - self.pcap.close() - os.unlink(self.pcap.name) + Base class for test helpers that run a daemon or a packet capture + in the background, e.g., a DHCP server or tshark. start() raises, + and leaves no process behind, if ready() fails. stop() sends + stop_signal and falls back to SIGKILL after five seconds. Calling + stop() when nothing runs does nothing. + + Use it as a context manager or call start() and stop() directly. + Subclasses implement argv() and may override ready(), stop_signal + and popen_kwargs, the extra arguments to Popen. + """ + stop_signal = signal.SIGTERM + popen_kwargs = {} + + def __init__(self, netns): + self.netns = netns + self.proc = None def __enter__(self): - self.start() - return self + return self.start() def __exit__(self, _, __, ___): self.stop() - def start(self): - assert self.proc == None, "Can't start an already running Pcap" + def argv(self): + """Command line to run in the namespace""" + raise NotImplementedError - 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) + def ready(self): + """Block until the process is ready to serve, raise if it fails""" - while " -- Capture started." not in self.proc.stderr.readline(): - pass + def running(self): + return self.proc is not None and self.proc.poll() is None - print("Capture running") + def start(self): + assert self.proc is None, f"{type(self).__name__} already running" - def stop(self, sleep=3): - assert self.proc, "Can't stop an already stopped Pcap" + self.proc = self.netns.popen(self.argv(), **self.popen_kwargs) + try: + self.ready() + except BaseException: + self.stop() + raise + + return self + + def stop(self): + """Stop the process, return (stdout, stderr) of any pipes""" + if not self.proc: + return None, None - 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) + proc, self.proc = self.proc, None + if proc.poll() is None: + proc.send_signal(self.stop_signal) - self.proc.terminate() try: - _, stderr = self.proc.communicate(5) - print(stderr) - return + return proc.communicate(timeout=5) except subprocess.TimeoutExpired: - try: - self.proc.kill() - except OSError: - pass + proc.kill() + return proc.communicate() + + +class Pcap(NetnsService): + popen_kwargs = dict(stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, text=True) + # In the common case, stop() is called right after the last packet + # of whatever is being tested was sent, so give it time to arrive. + stop_delay = 3 + + def __init__(self, netns, ifname, expr): + super().__init__(netns) + self.ifname, self.expr = ifname, expr + self.pcap = tempfile.NamedTemporaryFile(suffix=".pcap", delete=False) + + def __del__(self): + self.pcap.close() + os.unlink(self.pcap.name) + + def argv(self): + return f"tshark -ln -i {self.ifname} -w {self.pcap.name} {self.expr}".split() + + def ready(self): + while " -- Capture started." not in self.proc.stderr.readline(): + if self.proc.poll() is not None: + raise RuntimeError(f"tshark exited ({self.proc.returncode})") - self.proc.wait() + print("Capture running") + + def stop(self): + if self.running(): + time.sleep(self.stop_delay) + + _, stderr = super().stop() + if stderr: + print(stderr) def tcpdump(self, args=""): tcpdump = subprocess.run((f"tcpdump -r {self.pcap.name} -n " + args).split(), diff --git a/test/infamy/ntp_server.py b/test/infamy/ntp_server.py index 0392408d8..3d3a8e7e2 100644 --- a/test/infamy/ntp_server.py +++ b/test/infamy/ntp_server.py @@ -1,37 +1,24 @@ """Start NTP server in the background""" import subprocess -import time +from .netns import NetnsService -class Server: +class Server(NetnsService): """BusyBox ntpd serving the local clock (-l), never touching it (-w).""" + popen_kwargs = dict(stderr=subprocess.DEVNULL) def __init__(self, netns, iface="iface"): + super().__init__(netns) self.iface = iface - self.process = None - self.netns = netns - def __enter__(self): - self.start() - return self + def argv(self): + return ["ntpd", "-w", "-n", "-l", "-I", self.iface] - def __exit__(self, _, __, ___): - self.stop() - - def start(self): - cmd = ["ntpd", "-w", "-n", "-l", "-I", self.iface] - self.process = self.netns.popen(cmd, stderr=subprocess.DEVNULL) - - # ntpd exits immediately on bad options or a missing interface; + def ready(self): + # ntpd exits immediately on bad options or a missing interface, # fail loudly instead of serving nothing - time.sleep(1) - if self.process.poll() is not None: - code = self.process.returncode - self.stop() - raise RuntimeError(f"ntpd failed to start (exit {code})") - - def stop(self): - if self.process: - self.process.terminate() - self.process.wait() - self.process = None + try: + code = self.proc.wait(timeout=1) + except subprocess.TimeoutExpired: + return + raise RuntimeError(f"ntpd failed to start (exit {code})") diff --git a/test/infamy/sniffer.py b/test/infamy/sniffer.py index 58ff301da..fdba0fbf7 100644 --- a/test/infamy/sniffer.py +++ b/test/infamy/sniffer.py @@ -1,46 +1,14 @@ """Sniff for network packets using tcpdump/tshark""" -import os import signal -import subprocess -import tempfile -import time -import infamy.util as util +from .netns import Pcap -class Sniffer: - """Helper class for tcpdump""" - def __init__(self, netns, expr): - self.pcap = tempfile.NamedTemporaryFile(suffix=".pcap", delete=False) - self.expr = expr - self.netns = netns - self.proc = None - - def __del__(self): - self.pcap.close() - os.unlink(self.pcap.name) - - def __enter__(self): - cmd = f"tshark -lni iface -w {self.pcap.name} {self.expr}" - arg = cmd.split(" ") - self.proc = self.netns.popen(arg, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - text=True) +class Sniffer(Pcap): + """Capture on the namespace's "iface", read back with tcpdump""" + stop_signal = signal.SIGINT + stop_delay = 0 - util.until(lambda: " -- Capture started." in self.proc.stderr.readline()) - - def __exit__(self, _, __, ___): - if not self.proc: - return False - - self.proc.send_signal(signal.SIGINT) - time.sleep(1) - if not self.proc.poll(): - try: - self.proc.kill() - except OSError: - pass - self.proc.wait() + def __init__(self, netns, expr): + super().__init__(netns, "iface", expr) def output(self): """Return PCAP output"""