"""Shared helpers for pppd's integration test scripts. Each test builds one or more pairs of pppd processes joined back-to-back by an AF_UNIX socketpair on stdin/stdout (pppd's "notty" mode). Every pppd runs in its own network namespace, so the ONLY path between the two negotiated IP addresses is the ppp link itself; a ping between them genuinely traverses the link. A private mount namespace bind-mounts a per-peer etc.ppp directory over the binary's compiled-in configuration directory, keeping the tests hermetic (options, pap-secrets, chap-secrets) without touching the host's /etc/ppp. pppd must run as root, so when the suite is not run as root every privileged command is prefixed with "sudo -n"; tests skip when neither applies. Conventions matching the rsync-derived harness: * Exit codes (see the Exit enum): 0=pass, 1=fail, 2=error, 77=skip, 78=xfail. * The runner sets these environment variables before invoking each test: scratchdir per-test scratch directory TOOLDIR build directory PPPD path to the pppd binary under test PPPD_PEER pppd binary for the far side (version mixing) PPPD_CONFDIR(2) compiled-in confdir overrides (default: auto-detect) TESTRUN_TIMEOUT the runner's per-test timeout in seconds """ from __future__ import annotations import atexit import os import re import shlex import shutil import socket import subprocess import sys import time from pathlib import Path from exitcodes import Exit # re-exported: tests may `from pppfns import Exit` # --- environment ----------------------------------------------------------- def _required(name: str) -> str: v = os.environ.get(name) if not v: sys.stderr.write( f"pppfns: required environment variable {name} is not set; " "run this test via runtests.py rather than directly.\n" ) sys.exit(Exit.ERROR) return v SCRATCHDIR = Path(_required('scratchdir')) TOOLDIR = Path(_required('TOOLDIR')) PPPD = _required('PPPD') # The "peer" pppd binary -- used for the far side of each link. The runner # sets PPPD_PEER to a second binary when invoked with --pppd-bin2, letting a # run mix two pppd versions over the wire. When no second binary was # selected, PPPD_PEER == PPPD and nothing changes. PPPD_PEER = os.environ.get('PPPD_PEER', PPPD) TESTRUN_TIMEOUT = int(os.environ.get('TESTRUN_TIMEOUT', '120')) # pppd exit codes (pppd/pppd.h) EXIT_OK = 0 EXIT_FATAL_ERROR = 1 EXIT_OPTION_ERROR = 2 EXIT_USER_REQUEST = 5 EXIT_NEGOTIATION_FAILED = 10 EXIT_PEER_AUTH_FAILED = 11 EXIT_PEER_DEAD = 15 EXIT_HANGUP = 16 EXIT_AUTH_TOPEER_FAILED = 19 # Linux runs each pppd in its own network namespace; elsewhere (Solaris) # there are no namespaces, so both pppds share the host IP stack and only # the protocol-level tests can run (see require_link_env). IS_LINUX = sys.platform.startswith('linux') IS_SUNOS = os.uname().sysname == 'SunOS' # The kernel ppp device pppd opens differs per platform: /dev/ppp on Linux # (the ppp_generic driver), /dev/sppp on Solaris/illumos (the sppp STREAMS # driver -- see PPP_DRV_NAME in pppd/pppd-private.h). PPP_DEV = '/dev/sppp' if IS_SUNOS else '/dev/ppp' # The IP addresses used by every link test. With namespaces these never # collide between tests, even with -j; without them, derive a per-process # subnet (avoiding 10.0.2.x, which qemu user networking uses). if IS_LINUX: IP_A = '10.0.0.1' IP_B = '10.0.0.2' else: _n = os.getpid() IP_A = f'10.{100 + (_n >> 8) % 64}.{_n % 256}.1' IP_B = f'10.{100 + (_n >> 8) % 64}.{_n % 256}.2' # --- result reporting ------------------------------------------------------ def test_fail(msg: str) -> 'None': sys.stderr.write(msg.rstrip() + '\n') sys.exit(Exit.FAIL) def test_skipped(msg: str) -> 'None': sys.stderr.write(msg.rstrip() + '\n') (SCRATCHDIR / 'whyskipped').write_text(msg.rstrip() + '\n') sys.exit(Exit.SKIP) def test_xfail(msg: str) -> 'None': sys.stderr.write(msg.rstrip() + '\n') sys.exit(Exit.XFAIL) # --- privilege / environment gating ---------------------------------------- _sudo = None def sudo_prefix() -> list: """Command prefix for privileged operations: [] when running as root, ['sudo', '-n'] when passwordless sudo works, otherwise skip the test.""" global _sudo if _sudo is None: if os.geteuid() == 0: _sudo = [] else: try: r = subprocess.run(['sudo', '-n', 'true'], capture_output=True, timeout=10) ok = r.returncode == 0 except (FileNotFoundError, subprocess.TimeoutExpired): ok = False if not ok: test_skipped('pppd tests require root or passwordless sudo') _sudo = ['sudo', '-n'] return _sudo def require_link_env(routing: bool = False) -> 'None': """Skip unless this host can run a pppd pair: root/sudo, the namespace tools (Linux), and kernel ppp support. routing=True marks tests that send IP traffic across the link; those need per-pppd network namespaces (otherwise both negotiated addresses are local to one stack and traffic short-circuits via loopback), so they are Linux-only. Protocol-level tests run elsewhere too, but in a mode that writes secrets into the binary's real configuration directory -- only allowed when PPPD_TEST_GLOBAL_CONF=1 says the host is disposable (e.g. a CI VM).""" sudo = sudo_prefix() if IS_LINUX: tools = ('unshare', 'nsenter', 'ip', 'timeout', 'pkill') else: if routing: test_skipped('IP routing tests need Linux network namespaces') if os.environ.get('PPPD_TEST_GLOBAL_CONF') != '1': test_skipped('non-Linux link tests write to the real pppd ' 'config directory; set PPPD_TEST_GLOBAL_CONF=1 ' 'on a disposable host to allow') tools = ('pkill',) for tool in tools: if shutil.which(tool) is None: test_skipped(f'{tool} not found') if IS_LINUX and not os.path.exists(PPP_DEV): subprocess.run(sudo + ['modprobe', 'ppp_generic'], capture_output=True) if not os.path.exists(PPP_DEV): test_skipped(f'no kernel ppp support ({PPP_DEV} missing)') def pppd_confdir(binary: str) -> str: """The configuration directory the binary was compiled with (the dir it reads options/pap-secrets/chap-secrets from). Honours the runner's --pppd-confdir overrides, else scans the binary for the compiled-in "/pap-secrets" path string.""" if binary == PPPD and os.environ.get('PPPD_CONFDIR'): return os.environ['PPPD_CONFDIR'] if binary == PPPD_PEER and os.environ.get('PPPD_CONFDIR2'): return os.environ['PPPD_CONFDIR2'] with open(binary, 'rb') as f: data = f.read() m = re.search(rb'[!-~]+/pap-secrets', data) if m: return m.group(0)[:-len('/pap-secrets')].decode() return '/etc/ppp' # --- pppd instances --------------------------------------------------------- class PppPeer: """One pppd instance with its link on stdin/stdout (one end of a socketpair). On Linux it runs in its own network + mount namespace; elsewhere it shares the host stack and real configuration directory (see require_link_env).""" def __init__(self, name: str, binary: str, local_ip: str, remote_ip: str, options=None, noauth: bool = True, pap_secrets: str = None, chap_secrets: str = None, scripts: dict = None): self.name = name self.binary = binary self.local_ip = local_ip self.remote_ip = remote_ip self.proc = None self.pid = None self.ifname = None self.exitcode = None self.netns = None self.conf_cleanup = [] self.dir = SCRATCHDIR / name self.logfile = self.dir / 'pppd.log' self.pidfile = self.dir / 'pppd.pid' self.errfile = self.dir / 'spawn.err' confdir = pppd_confdir(binary) etc_ppp = self.dir / 'etc.ppp' etc_ppp.mkdir(parents=True) conf_files = [] # (name, mode) pairs staged by launch.sh below def stage(fname, text, mode): (self.dir / fname).write_text(text) if IS_LINUX: # pppd refuses a secrets file unless its resolved path # consists entirely of root-owned, non-group/other- # writable components, which a scratch dir under $HOME # can never satisfy. The check walks the realpath, so # symlink the file into a root-owned tmpfs dir that # launch.sh populates inside the mount namespace. (etc_ppp / fname).symlink_to(f'/run/ppp-conf/{fname}') else: # No mount namespace: launch.sh copies the file into the # real confdir (PPPD_TEST_GLOBAL_CONF gate); remove it # again in stop(). self.conf_cleanup.append(f'{confdir}/{fname}') conf_files.append((fname, mode)) for fname, text in (('pap-secrets', pap_secrets), ('chap-secrets', chap_secrets)): if text is not None: stage(fname, text, '600') # Hook scripts pppd execs itself (ip-up, ip-down, ...). They must be # root-owned, non-group/other-writable and executable or # ppp_check_access() refuses to run them. for fname, text in (scripts or {}).items(): stage(fname, text, '755') if IS_LINUX: # The bind-mounted confdir replaces the host's, so provide the # files pppd reads from it. An empty options file keeps the run # hermetic. (etc_ppp / 'options').write_text('') # Pre-create the log as the invoking user: pppd appends (O_APPEND), # so the file stays user-owned and scratch cleanup works unprivileged. self.logfile.touch() argv = [binary, 'notty', 'nodetach', 'local', 'debug', 'logfile', str(self.logfile), # Belt-and-braces lifetime bounds: no persist, dead-peer # detection via LCP echos, and a cap on connected time # (maxconnect only arms once the link is up; the timeout # wrapper below is the wall-clock backstop). 'maxconnect', str(2 * TESTRUN_TIMEOUT), 'lcp-echo-interval', '1', 'lcp-echo-failure', '10'] if noauth: argv.append('noauth') argv += list(options or []) argv.append(f'{local_ip}:{remote_ip}') # Root-side wall-clock watchdog: pppd dies even if it wedges before # the link comes up and the test runner was SIGKILLed (which can't # reap root processes). timeout(1) forwards SIGTERM and propagates # pppd's exit status, so clean-shutdown tests are unaffected. # Required on Linux; used elsewhere when available. self.has_watchdog = shutil.which('timeout') is not None if self.has_watchdog: argv = ['timeout', '--kill-after=10', str(2 * TESTRUN_TIMEOUT)] + argv q = shlex.quote script = ['#!/bin/sh', 'set -e'] if IS_LINUX: # mkdir -p of a missing compiled-in confdir (e.g. # /usr/local/etc/ppp for a fresh build) is the one tolerated # host-side effect; the bind mount itself is private to this # mount namespace, as is the tmpfs hiding the pidfile/tdb # directory under /run. script += [ # mode=755: the default tmpfs 1777 would itself trip pppd's # secrets-path safety check on /run. 'mount -t tmpfs -o mode=755 tmpfs /run', '[ ! -d /var/run ] || [ -L /var/run ] || mount -t tmpfs -o mode=755 tmpfs /var/run', f"mkdir -p {q(confdir)}", f"mount --bind {q(str(etc_ppp))} {q(confdir)}", 'mkdir -m 755 /run/ppp-conf'] for fname, mode in conf_files: script += [f"cp {q(str(self.dir / fname))} /run/ppp-conf/{fname}", f"chmod {mode} /run/ppp-conf/{fname}"] script += ['ip link set lo up'] else: script += [f"mkdir -p {q(confdir)}"] for fname, mode in conf_files: dst = f'{confdir}/{fname}' # Never clobber a real secrets file or hook script, even on # an opted-in host. script += [f"if [ -e {q(dst)} ]; then echo {q(dst)} already exists >&2; exit 1; fi", f"cp {q(str(self.dir / fname))} {q(dst)}", f"chmod {mode} {q(dst)}"] script += [ # $$ survives the exec, so this records the watchdog's (or # pppd's) pid; on Linux it also names the network namespace # via /proc//ns/net. f"echo $$ > {q(str(self.pidfile))}", 'exec ' + ' '.join(q(a) for a in argv)] launch = self.dir / 'launch.sh' launch.write_text('\n'.join(script) + '\n') self.launch = launch def start(self, sock) -> 'None': """Start pppd with its link on the given socket (one socketpair end).""" if IS_LINUX: wrapper = ['unshare', '--mount', '--net', '--propagation', 'private'] else: wrapper = [] with open(self.errfile, 'w') as errfh: self.proc = subprocess.Popen( sudo_prefix() + wrapper + ['sh', str(self.launch)], stdin=sock, stdout=sock, stderr=errfh) atexit.register(self.stop) deadline = time.time() + 10 while time.time() < deadline: # Check the pidfile before the process: a pppd that legitimately # ran and exited within one poll interval (e.g. a fast auth # reject) has still started -- tests observe it via its log and # exit code. try: self.pid = int(self.pidfile.read_text()) break except (FileNotFoundError, ValueError): pass if self.proc.poll() is not None: # The pidfile may have appeared between the read above and # the exit; only a missing pidfile means launch.sh failed. try: self.pid = int(self.pidfile.read_text()) break except (FileNotFoundError, ValueError): test_fail(f'pppd {self.name} failed to start ' f'(exit {self.proc.returncode}):\n' + self.errfile.read_text()) time.sleep(0.05) if self.pid is None: test_fail(f'pppd {self.name} did not write its pid file') if IS_LINUX: self.netns = f'/proc/{self.pid}/ns/net' def log_text(self) -> str: try: return self.logfile.read_text() except OSError: return '' def wait_for_text(self, pattern: str, timeout: float = 30) -> 're.Match': """Wait until the pppd log matches the regex; fail with the log tail.""" deadline = time.time() + timeout while True: m = re.search(pattern, self.log_text()) if m: return m if time.time() >= deadline: tail = '\n'.join(self.log_text().splitlines()[-20:]) test_fail(f"pppd {self.name}: no '{pattern}' in log after " f"{timeout}s; log tail:\n{tail}") time.sleep(0.05) def wait_link_up(self, timeout: float = 30) -> 'None': """Wait for IPCP to come up with the expected addresses.""" self.wait_for_text(rf'local +IP address {re.escape(self.local_ip)}', timeout) self.wait_for_text(rf'remote IP address {re.escape(self.remote_ip)}', timeout) m = self.wait_for_text(r'Using interface (\S+)', timeout) self.ifname = m.group(1) def _ns_argv(self, argv) -> list: if self.netns is None: return sudo_prefix() + list(argv) return sudo_prefix() + ['nsenter', f'--net={self.netns}', '--'] + list(argv) def run_in_ns(self, argv, check: bool = True, **kwargs) -> 'subprocess.CompletedProcess': """Run a command inside this pppd's network namespace (or on the host when there is no namespace).""" kwargs.setdefault('capture_output', True) kwargs.setdefault('text', True) r = subprocess.run(self._ns_argv(argv), **kwargs) if check and r.returncode != 0: test_fail(f'pppd {self.name}: command {argv} failed ' f'(exit {r.returncode}):\n{r.stdout}\n{r.stderr}') return r def popen_in_ns(self, argv, **kwargs) -> 'subprocess.Popen': """Start a background command inside this pppd's network namespace.""" return subprocess.Popen(self._ns_argv(argv), **kwargs) def ping(self, target_ip: str, count: int = 3, size: int = None) -> int: """Ping target_ip from inside this peer's namespace; returns the number of replies received.""" argv = ['ping', '-c', str(count), '-i', '0.2', '-W', '5'] if size is not None: argv += ['-s', str(size)] r = self.run_in_ns(argv + [target_ip], check=False) m = re.search(r'(\d+) (?:packets )?received', r.stdout) return int(m.group(1)) if m else 0 def ip_addr(self) -> str: if shutil.which('ip'): return self.run_in_ns(['ip', '-o', 'addr', 'show', 'dev', self.ifname]).stdout # Solaris: ifconfig prints "inet --> ..." return self.run_in_ns(['ifconfig', self.ifname]).stdout def link_mtu(self) -> int: if shutil.which('ip'): out = self.run_in_ns(['ip', '-o', 'link', 'show', 'dev', self.ifname]).stdout else: out = self.run_in_ns(['ifconfig', self.ifname]).stdout m = re.search(r'mtu (\d+)', out) if not m: test_fail(f'pppd {self.name}: no mtu in: {out}') return int(m.group(1)) def expect_exit(self, codes, timeout: float = 60) -> int: """Wait for pppd to exit of its own accord; fail unless its exit code is one of `codes`. sudo propagates pppd's exit status.""" try: self.exitcode = self.proc.wait(timeout=timeout) except subprocess.TimeoutExpired: self.stop() test_fail(f'pppd {self.name} still running after {timeout}s ' f'(expected exit in {sorted(codes)})') if self.exitcode not in codes: test_fail(f'pppd {self.name} exited {self.exitcode}, ' f'expected one of {sorted(codes)}; log tail:\n' + '\n'.join(self.log_text().splitlines()[-20:])) return self.exitcode def stop(self, timeout: float = 10) -> int: """Terminate pppd (clean LCP shutdown via SIGTERM, then SIGKILL) and record its exit code.""" if self.proc is None or self.exitcode is not None: return self.exitcode def signal_pppd(sig): # pppd runs as root; signal it via sudo when unprivileged. When # a timeout watchdog wraps pppd, the recorded pid is the # watchdog's, so signal its child (pppd itself, via pkill -P): # TERMing the wrapper would make it exit 124 and lose pppd's # exit status. if self.has_watchdog: subprocess.run(sudo_prefix() + ['pkill', f'-{sig}', '-P', str(self.pid)], capture_output=True) else: subprocess.run(sudo_prefix() + ['kill', f'-{sig}', str(self.pid)], capture_output=True) if self.proc.poll() is None and self.pid is not None: signal_pppd('TERM') try: self.exitcode = self.proc.wait(timeout=timeout) except subprocess.TimeoutExpired: if self.pid is not None: signal_pppd('KILL') subprocess.run(sudo_prefix() + ['kill', '-KILL', str(self.pid)], capture_output=True) try: self.exitcode = self.proc.wait(timeout=10) except subprocess.TimeoutExpired: # Can't signal a root process whose pid we never learned; # the root-side timeout watchdog bounds its lifetime, so # don't hang the test waiting for it. sys.stderr.write(f'pppd {self.name}: could not reap ' f'(left to its watchdog)\n') self.exitcode = -1 for path in self.conf_cleanup: subprocess.run(sudo_prefix() + ['rm', '-f', path], capture_output=True) self.conf_cleanup = [] return self.exitcode class PppPair: """Two pppd instances ('a' using PPPD, 'b' using PPPD_PEER) joined by a socketpair. Use as a context manager so both sides are torn down on any test failure.""" def __init__(self, a_options=None, b_options=None, a_kwargs=None, b_kwargs=None, name: str = ''): prefix = name + '-' if name else '' self.a = PppPeer(prefix + 'a', PPPD, IP_A, IP_B, options=a_options, **(a_kwargs or {})) self.b = PppPeer(prefix + 'b', PPPD_PEER, IP_B, IP_A, options=b_options, **(b_kwargs or {})) def start(self) -> 'PppPair': sock_a, sock_b = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) self.a.start(sock_a) self.b.start(sock_b) # The charshunt children hold the link now; drop our copies so the # pair fully closes when both pppds exit. sock_a.close() sock_b.close() return self def up(self, timeout: float = 30) -> 'PppPair': self.start() self.a.wait_link_up(timeout) self.b.wait_link_up(timeout) return self def down(self) -> 'None': self.b.stop() self.a.stop() def __enter__(self) -> 'PppPair': return self def __exit__(self, *exc) -> bool: self.down() return False