"""Test support: a throwaway sshd standing in for a tool VM (L13). Runs the system's sshd as the current user, on a free local port, with a host key and client key made for the occasion. Tests then reach "the build VM" over real SSH, so the code path under test is the production path. """ import getpass import os import socket import subprocess import time from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path from nisse_dev.execution import SSHHost SSHD = "/usr/sbin/sshd" def _free_port() -> int: with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) return int(sock.getsockname()[1]) def _keygen(path: Path) -> None: subprocess.run( ["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(path)], check=True ) @contextmanager def local_sshd(root: Path) -> Iterator[SSHHost]: """An SSHHost for a private sshd on 127.0.0.1, torn down afterwards.""" root.mkdir(parents=True, exist_ok=True) host_key, client_key = root / "host_key", root / "client_key" _keygen(host_key) _keygen(client_key) authorized = root / "authorized_keys" authorized.write_text(client_key.with_suffix(".pub").read_text()) authorized.chmod(0o600) port = _free_port() config = root / "sshd_config" config.write_text( "\n".join( [ f"Port {port}", "ListenAddress 127.0.0.1", f"HostKey {host_key}", f"AuthorizedKeysFile {authorized}", f"PidFile {root / 'sshd.pid'}", "PasswordAuthentication no", "KbdInteractiveAuthentication no", "PubkeyAuthentication yes", "PermitRootLogin prohibit-password", "StrictModes no", "UsePAM no", "MaxSessions 50", "MaxStartups 50", # Stands in for a provisioned build VM: sessions see the test # runner's PATH, so its Python and tools are "installed". f"SetEnv PATH={os.environ.get('PATH', '/usr/bin:/bin')}", "", ] ) ) process = subprocess.Popen( [SSHD, "-D", "-e", "-f", str(config)], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) user = getpass.getuser() host = SSHHost( "127.0.0.1", key=client_key, state_dir=root / "client", user=user, port=port ) try: deadline = time.monotonic() + 10 while True: if process.poll() is not None: raise RuntimeError( "sshd exited: " + (process.stderr.read() or b"").decode() if process.stderr else "sshd exited" ) try: with socket.create_connection(("127.0.0.1", port), timeout=0.5): break except OSError: if time.monotonic() > deadline: raise RuntimeError("sshd did not start") from None time.sleep(0.05) yield host finally: host.close() process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill()