From 7e50d05f9c0e2606b1e462cca63fb58b0d8b81c5 Mon Sep 17 00:00:00 2001 From: Ejub Sabic Date: Thu, 8 Oct 2026 10:46:14 +0200 Subject: [PATCH 1/2] test/infamy: add common base class for netns services This commit introduces common base class for all netns services. Resolves: #1265 Signed-off-by: Ejub Sabic --- test/infamy/netns.py | 65 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/test/infamy/netns.py b/test/infamy/netns.py index c1bb678ee..e10101833 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,6 +346,70 @@ def pcap(self, expr, ifname=None): ifname = ifname if ifname else self._ifname return super().pcap(expr=expr, ifname=ifname) +class NetnsService: + """A long-running process inside a network namespace + + 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): + return self.start() + + def __exit__(self, _, __, ___): + self.stop() + + def argv(self): + """Command line to run in the namespace""" + raise NotImplementedError + + def ready(self): + """Block until the process is ready to serve, raise if it fails""" + + def running(self): + return self.proc is not None and self.proc.poll() is None + + def start(self): + assert self.proc is None, f"{type(self).__name__} already running" + + 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 + + proc, self.proc = self.proc, None + if proc.poll() is None: + proc.send_signal(self.stop_signal) + + try: + return proc.communicate(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + return proc.communicate() + + class Pcap: def __init__(self, netns, ifname, expr): self.netns, self.ifname, self.expr = netns, ifname, expr From 0957bd7573174ce2e9bbf72965538d986c6e154e Mon Sep 17 00:00:00 2001 From: Ejub Sabic Date: Thu, 8 Oct 2026 10:48:18 +0200 Subject: [PATCH 2/2] test/infamy: run all netns helpers on NetnsService This commit ports the DHCP, NTP and HTTP servers, Pcap, the multicast helpers and the DNAT echo server in Firewall to the new base netns class. Sniffer was a copy of Pcap and is now a thin subclass of it. Signed-off-by: Ejub Sabic --- test/infamy/dhcp.py | 99 +++++++------------------------------- test/infamy/file_server.py | 34 +++---------- test/infamy/firewall.py | 32 +++++++----- test/infamy/multicast.py | 88 +++++++-------------------------- test/infamy/netns.py | 59 ++++++++--------------- test/infamy/ntp_server.py | 39 +++++---------- test/infamy/sniffer.py | 46 +++--------------- 7 files changed, 100 insertions(+), 297 deletions(-) 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 e10101833..dd90fe3b5 100644 --- a/test/infamy/netns.py +++ b/test/infamy/netns.py @@ -410,60 +410,39 @@ def stop(self): return proc.communicate() -class Pcap: +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): - self.netns, self.ifname, self.expr = netns, ifname, expr + super().__init__(netns) + self.ifname, self.expr = 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) + 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(): - pass + if self.proc.poll() is not None: + raise RuntimeError(f"tshark exited ({self.proc.returncode})") 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) + def stop(self): + if self.running(): + time.sleep(self.stop_delay) - self.proc.terminate() - try: - _, stderr = self.proc.communicate(5) + _, stderr = super().stop() + if stderr: 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(), 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"""