From d6ba2a2e55416294bca1ae87499bfb31d94ccd4d Mon Sep 17 00:00:00 2001 From: klzgrad Date: Mon, 5 Oct 2026 23:41:35 +0800 Subject: [PATCH] Improve tests --- tests/basic.py | 696 +++++++++++++++++++++++------------------ tests/ssl_cert_file.py | 268 ++++++++++++++++ 2 files changed, 653 insertions(+), 311 deletions(-) create mode 100644 tests/ssl_cert_file.py diff --git a/tests/basic.py b/tests/basic.py index d012bb8278..7bc44c4150 100644 --- a/tests/basic.py +++ b/tests/basic.py @@ -1,340 +1,414 @@ #!/usr/bin/env python3 +"""Exercise naive's configuration, authentication, and proxy chaining. + +Run with --naive=/path/to/naive and --server_protocol=https or http. +Four cases require port 1080; use --skip-default-port-tests if it is occupied. +The existing --rootfs and --target_cpu options support bwrap and qemu-user. +""" + import argparse +from collections import deque +import contextlib import http.server import os -import sys -import shutil +from pathlib import Path +import queue +import shlex +import socket import ssl import subprocess +import sys import tempfile import threading import time -parser = argparse.ArgumentParser() -parser.add_argument('--naive', required=True) -parser.add_argument('--rootfs') -parser.add_argument('--target_cpu') -parser.add_argument('--server_protocol', - choices=['http', 'https'], default='https') -argv = parser.parse_args() -if argv.rootfs: - try: - os.remove(os.path.join(argv.rootfs, 'naive')) - except OSError: +HOST = '127.0.0.1' +PAYLOAD = b'Naive basic integration test\n' * 128 +QEMU_CPUS = { + 'arm64': 'aarch64', + 'arm': 'arm', + 'mipsel': 'mipsel', + 'mips64el': 'mips64el', + 'riscv64': 'riscv64', + 'loong64': 'loong64', +} + + +class TestFailure(Exception): + pass + + +class ListenFailure(TestFailure): + pass + + +class OriginHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Content-Length', str(len(PAYLOAD))) + self.end_headers() + self.wfile.write(PAYLOAD) + + def log_message(self, *args): pass -server_protocol = argv.server_protocol -_, certfile = tempfile.mkstemp() - -result = subprocess.run( - f'openssl req -new -x509 -keyout {certfile} -out {certfile} -days 1 -nodes -subj /C=XX'.split(), capture_output=True) -result.check_returncode() - -HTTPS_SERVER_HOSTNAME = '127.0.0.1' -HTTP_SERVER_PORT = 60443 if server_protocol == 'https' else 60080 - -httpd = http.server.HTTPServer( - (HTTPS_SERVER_HOSTNAME, HTTP_SERVER_PORT), http.server.SimpleHTTPRequestHandler) -httpd.timeout = 1 -httpd.allow_reuse_address = True -if server_protocol == 'https': - ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ssl_context.load_cert_chain(certfile=certfile) - httpd.socket = ssl_context.wrap_socket(httpd.socket, server_side=True) - -httpd_thread = threading.Thread( - target=lambda httpd: httpd.serve_forever(), args=(httpd,), daemon=True) -httpd_thread.start() +@contextlib.contextmanager +def origin_server(protocol, directory): + with http.server.ThreadingHTTPServer((HOST, 0), OriginHandler) as server: + if protocol == 'https': + certfile = directory / 'origin.pem' + # A P-256 key is quick to generate. This certificate belongs to + # curl's destination; naive only relays the encrypted bytes. + result = subprocess.run( + ['openssl', 'req', '-new', '-x509', '-newkey', 'ec', + '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes', + '-keyout', str(certfile), '-out', str(certfile), + '-days', '1', '-subj', '/CN=localhost'], + capture_output=True, timeout=10, text=True) + if result.returncode: + raise TestFailure(f'Certificate generation failed:\n{result.stderr}') + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certfile) + server.socket = context.wrap_socket(server.socket, server_side=True) + thread = threading.Thread( + target=server.serve_forever, kwargs={'poll_interval': 0.05}, daemon=True) + thread.start() + try: + yield server.server_port + finally: + server.shutdown() + thread.join() -def test_https_server(hostname, port, proxy=None): - url = f'{server_protocol}://{hostname}:{port}/404' - cmdline = ['curl', '-k', '-s'] - if proxy: - cmdline.extend(['--proxy', proxy]) - cmdline.append(url) - print('subprocess.run', ' '.join(cmdline)) - result = subprocess.run(cmdline, capture_output=True, - timeout=10, text=True, encoding='utf-8') - print(result.stderr, end='') - return 'Error code: 404' in result.stdout +class BasicTests: + def __init__(self, options, directory, origin_port): + self.options = options + self.directory = directory + self.origin_port = origin_port + self.passed = 0 + self.skipped = 0 + def request(self, proxy=None): + url = f'{self.options.server_protocol}://{HOST}:{self.origin_port}/' + # Ignore ~/.curlrc, NO_PROXY, and inherited proxy settings. Every + # proxied case must actually traverse the client under test. + command = ['curl', '--disable', '--insecure', '--silent', '--show-error', + '--fail', '--max-time', '10', '--noproxy', '', + '--proxy', proxy or '', url] + result = subprocess.run(command, capture_output=True, timeout=15) + if result.returncode or result.stdout != PAYLOAD: + raise TestFailure( + f'{shlex.join(command)} failed (exit {result.returncode}):\n' + f'{result.stderr.decode(errors="replace")}\n' + f'Received {len(result.stdout)} bytes; expected {len(PAYLOAD)}') -assert test_https_server(HTTPS_SERVER_HOSTNAME, - HTTP_SERVER_PORT), 'https server not up' - - -def start_naive(naive_args, config_file): - with_qemu = None - if argv.target_cpu == 'arm64': - with_qemu = 'aarch64' - elif argv.target_cpu == 'arm': - with_qemu = 'arm' - elif argv.target_cpu == 'mipsel': - with_qemu = 'mipsel' - elif argv.target_cpu == 'mips64el': - with_qemu = 'mips64el' - elif argv.target_cpu == 'riscv64': - with_qemu = 'riscv64' - elif argv.target_cpu == 'loong64': - with_qemu = 'loong64' - - if argv.rootfs: - if not with_qemu: - if not os.path.exists(os.path.join(argv.rootfs, 'naive')): - shutil.copy2(argv.naive, argv.rootfs) - # bwrap isolates filesystem, config_file needs a copy inside. - if config_file is not None: - shutil.copy2(config_file, argv.rootfs) - cmdline = ['bwrap', '--die-with-parent', '--bind', argv.rootfs, '/', - '--proc', '/proc', '--dev', '/dev', '--chdir', '/', '/naive'] + @contextlib.contextmanager + def start_naive(self, arguments): + qemu = QEMU_CPUS.get(self.options.target_cpu) + if self.options.rootfs: + if qemu: + command = [f'qemu-{qemu}', '-L', self.options.rootfs, + self.options.naive] + else: + # Mount the private test directory and binary rather than + # copying config files or replacing /naive in the rootfs. + (self.directory / 'naive').touch(exist_ok=True) + command = ['bwrap', '--die-with-parent', + '--bind', self.options.rootfs, '/', + '--bind', str(self.directory), '/tmp', + '--ro-bind', self.options.naive, '/tmp/naive', + '--proc', '/proc', '--dev', '/dev', + '--chdir', '/tmp', '/tmp/naive'] else: - cmdline = [f'qemu-{with_qemu}', - '-L', argv.rootfs, argv.naive] - else: - cmdline = [argv.naive] - cmdline.extend(naive_args) + command = [self.options.naive] + command.extend(arguments) + env = os.environ.copy() + env['TEST_MARK_STARTUP'] = 'yes' + proc = subprocess.Popen(command, cwd=self.directory, env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, + text=True, encoding='utf-8', errors='replace') + lines = deque(maxlen=2000) + startup = queue.Queue() - env = os.environ.copy() - env["TEST_MARK_STARTUP"] = "yes" - proc = subprocess.Popen(cmdline, stdout=subprocess.DEVNULL, env=env, - stderr=subprocess.PIPE, text=True, encoding='utf-8') - print('subprocess.Popen', ' '.join(cmdline), 'pid:', proc.pid) + def drain_stderr(): + try: + for line in proc.stderr: + lines.append(line) + if 'Failed to listen' in line or 'TEST_MARK_STARTUP' in line: + startup.put(line) + finally: + startup.put(None) - def terminate(proc): - print('proc has timed out') - print('terminate pid', proc.pid) - proc.terminate() + reader = threading.Thread(target=drain_stderr, daemon=True) + reader.start() + failed = False + try: + # Keep the existing allowance for slow qemu-user startup. + deadline = time.monotonic() + 20 + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TestFailure('naive did not start within 20 seconds') + try: + line = startup.get(timeout=remaining) + except queue.Empty: + raise TestFailure('naive did not start within 20 seconds') from None + if line is None: + raise TestFailure(f'naive exited before startup (exit {proc.wait()})') + if 'Failed to listen' in line: + raise ListenFailure(line.strip()) + if 'TEST_MARK_STARTUP' in line: + break + yield proc + except BaseException: + failed = True + raise + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + reader.join() + proc.stderr.close() + if failed: + print(f'Client log: {shlex.join(command)}', file=sys.stderr) + print(''.join(lines), file=sys.stderr, end='') - # Some qemu-user tests take a while to start. - # See https://gitlab.com/qemu-project/qemu/-/issues/1729 - timeout = threading.Timer(20, terminate, args=(proc,)) - timeout.start() - while True: - if proc.poll() is not None: - timeout.cancel() - return proc.poll() == 0 + def test_naive_once(self, proxy, *arguments, **kwargs): + allocated = set() - line = proc.stderr.readline().strip() - print(line) - if 'Failed to listen' in line: - timeout.cancel() - print('terminate pid', proc.pid) - proc.terminate() - return 'Failed to listen' - elif 'TEST_MARK_STARTUP' in line: - timeout.cancel() - return proc + class PortDict(dict): + def __missing__(self, key): + if not key.startswith('PORT'): + return key + # Binding without port reuse detects occupied listeners, + # including naive listeners that enable SO_REUSEPORT. + while True: + with socket.socket() as sock: + sock.bind((HOST, 0)) + port = sock.getsockname()[1] + if port >= 1024 and port not in allocated: + break + allocated.add(port) + self[key] = str(port) + return self[key] + + ports = PortDict() + proxy = proxy.format_map(ports) + config_file = kwargs.get('config_file') + config_content = kwargs.get('config_content') + config_path = self.directory / config_file if config_file else None + try: + if config_content is not None: + config_path.write_text('{' + config_content.format_map(ports) + '}') + with contextlib.ExitStack() as clients: + processes = [] + for arguments_instance in arguments: + naive_arguments = shlex.split(arguments_instance.format_map(ports)) + processes.append(clients.enter_context(self.start_naive(naive_arguments))) + self.request(proxy) + if any(proc.poll() is not None for proc in processes): + raise TestFailure('A client exited during the request') + finally: + if config_path is not None: + config_path.unlink(missing_ok=True) + + def test_naive(self, label, proxy, *arguments, **kwargs): + reason = None + if self.options.target_cpu == 'arm' and not label.startswith('Default'): + # Preserve the existing skips for slow arm qemu-user tests. + reason = 'slow arm qemu-user test' + elif kwargs.get('uses_default_port'): + if self.options.skip_default_port_tests: + reason = '--skip-default-port-tests' + else: + with socket.socket() as sock: + # Allow old connections in TIME_WAIT, but do not enable + # SO_REUSEPORT, which would hide an active listener. + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(('0.0.0.0', 1080)) + except OSError: + raise TestFailure( + f'{label}: port 1080 is occupied; use ' + '--skip-default-port-tests to omit the four cases ' + 'that require the default port') from None + if reason: + self.skipped += 1 + print(f'** SKIP TEST: {label} ({reason})', flush=True) + return + + for attempt in range(5): + try: + self.test_naive_once(proxy, *arguments, **kwargs) + break + except ListenFailure: + if attempt == 4: + raise + # Cleanup has reaped every client. Retry with fresh ports; + # there is no reason to sleep for a fixed second. + print(f'Retrying {label} with new ports...', flush=True) + except TestFailure as error: + raise TestFailure(f'{label}: {error}') from error + self.passed += 1 + print(f'** TEST PASS: {label}', flush=True) -port = 10000 +def run_tests(test_naive): + test_naive('Default config', 'socks5h://127.0.0.1:1080', + '--log', uses_default_port=True) + + test_naive('Default config file', 'socks5h://127.0.0.1:{PORT1}', + '', + config_content='"listen":"socks://127.0.0.1:{PORT1}","log":""', + config_file='config.json') + + test_naive('Custom config file', 'socks5h://127.0.0.1:{PORT1}', + 'custom.json', + config_content='"listen":"socks://127.0.0.1:{PORT1}","log":""', + config_file='custom.json') + + test_naive('Multiple listens - command line', 'socks5h://127.0.0.1:{PORT1}', + '--log --listen=socks://:{PORT1} --listen=http://:{PORT2}') + + test_naive('Multiple listens - command line', 'http://127.0.0.1:{PORT2}', + '--log --listen=socks://:{PORT1} --listen=http://:{PORT2}') + + test_naive('Multiple listens - config file', 'socks5h://127.0.0.1:{PORT1}', + 'multiple-listen.json', + config_content='"listen":["socks://:{PORT1}", "http://:{PORT2}"],"log":""', + config_file='multiple-listen.json') + + test_naive('Multiple listens - config file', 'http://127.0.0.1:{PORT2}', + 'multiple-listen.json', + config_content='"listen":["socks://:{PORT1}", "http://:{PORT2}"],"log":""', + config_file='multiple-listen.json') + + test_naive('Multiple proxies - command line', 'socks5h://127.0.0.1:{PORT1}', + '--log --listen=socks://:{PORT1} --listen=socks://:{PORT2} --proxy=http://127.0.0.1:{PORT3} --proxy=http://127.0.0.1:{PORT4}', + '--log --listen=http://:{PORT3} --listen=http://:{PORT4} --proxy=socks://127.0.0.1:{PORT5}', + '--log --listen=socks://:{PORT5}') + + test_naive('Multiple proxies - command line', 'socks5h://127.0.0.1:{PORT2}', + '--log --listen=socks://:{PORT1} --listen=socks://:{PORT2} --proxy=http://127.0.0.1:{PORT3} --proxy=http://127.0.0.1:{PORT4}', + '--log --listen=http://:{PORT3} --listen=http://:{PORT4} --proxy=socks://127.0.0.1:{PORT5}', + '--log --listen=socks://:{PORT5}') + + test_naive('Multiple proxies - different auth', 'socks5h://127.0.0.1:{PORT1}', + '--log --listen=socks://:{PORT1} --listen=socks://:{PORT2} --proxy=http://user1:pass1@127.0.0.1:{PORT3} --proxy=http://user2:pass2@127.0.0.1:{PORT3}', + '--log --listen=http://user1:pass1@127.0.0.1:{PORT3}') + + test_naive('Multiple proxies - different auth', 'socks5h://127.0.0.1:{PORT2}', + '--log --listen=socks://:{PORT1} --listen=socks://:{PORT2} --proxy=http://user1:pass1@127.0.0.1:{PORT3} --proxy=http://user2:pass2@127.0.0.1:{PORT3}', + '--log --listen=http://user2:pass2@127.0.0.1:{PORT3}') + + test_naive('Trivial - listen scheme only', 'socks5h://127.0.0.1:1080', + '--log --listen=socks://', uses_default_port=True) + + test_naive('Trivial - listen no host', 'socks5h://127.0.0.1:{PORT1}', + '--log --listen=socks://:{PORT1}') + + test_naive('Trivial - listen no port', 'socks5h://127.0.0.1:1080', + '--log --listen=socks://127.0.0.1', uses_default_port=True) + + test_naive('Trivial - auth', 'socks5h://user:pass@127.0.0.1:{PORT1}', + '--log --listen=socks://user:pass@127.0.0.1:{PORT1}') + + test_naive('Trivial - auth with special chars', 'socks5h://user:^@127.0.0.1:{PORT1}', + '--log --listen=socks://user:^@127.0.0.1:{PORT1}') + + test_naive('Trivial - auth with special chars', 'socks5h://^:^@127.0.0.1:{PORT1}', + '--log --listen=socks://^:^@127.0.0.1:{PORT1}') + + test_naive('Trivial - auth with empty pass', 'socks5h://user:@127.0.0.1:{PORT1}', + '--log --listen=socks://user:@127.0.0.1:{PORT1}') + + test_naive('SOCKS-SOCKS', 'socks5h://127.0.0.1:{PORT1}', + '--log --listen=socks://:{PORT1} --proxy=socks://127.0.0.1:{PORT2}', + '--log --listen=socks://:{PORT2}') + + test_naive('SOCKS-SOCKS - proxy no port', 'socks5h://127.0.0.1:{PORT1}', + '--log --listen=socks://:{PORT1} --proxy=socks://127.0.0.1', + '--log --listen=socks://:1080', uses_default_port=True) + + test_naive('SOCKS-HTTP', 'socks5h://127.0.0.1:{PORT1}', + '--log --listen=socks://:{PORT1} --proxy=http://127.0.0.1:{PORT2}', + '--log --listen=http://:{PORT2}') + + test_naive('HTTP-HTTP', 'http://127.0.0.1:{PORT1}', + '--log --listen=http://:{PORT1} --proxy=http://127.0.0.1:{PORT2}', + '--log --listen=http://:{PORT2}') + + test_naive('HTTP-SOCKS', 'http://127.0.0.1:{PORT1}', + '--log --listen=http://:{PORT1} --proxy=socks://127.0.0.1:{PORT2}', + '--log --listen=socks://:{PORT2}') + + test_naive('SOCKS-SOCKS-SOCKS', 'socks5h://127.0.0.1:{PORT1}', + '--log --listen=socks://:{PORT1} --proxy=socks://127.0.0.1:{PORT2}', + '--log --listen=socks://:{PORT2} --proxy=socks://127.0.0.1:{PORT3}', + '--log --listen=socks://:{PORT3}') + + test_naive('SOCKS-HTTP-SOCKS', 'socks5h://127.0.0.1:{PORT1}', + '--log --listen=socks://:{PORT1} --proxy=http://127.0.0.1:{PORT2}', + '--log --listen=http://:{PORT2} --proxy=socks://127.0.0.1:{PORT3}', + '--log --listen=socks://:{PORT3}') + + test_naive('HTTP-SOCKS-HTTP', 'http://127.0.0.1:{PORT1}', + '--log --listen=http://:{PORT1} --proxy=socks://127.0.0.1:{PORT2}', + '--log --listen=socks://:{PORT2} --proxy=http://127.0.0.1:{PORT3}', + '--log --listen=http://:{PORT3}') + + test_naive('HTTP-HTTP-HTTP', 'http://127.0.0.1:{PORT1}', + '--log --listen=http://:{PORT1} --proxy=http://127.0.0.1:{PORT2}', + '--log --listen=http://:{PORT2} --proxy=http://127.0.0.1:{PORT3}', + '--log --listen=http://:{PORT3}') + + test_naive('HTTP-HTTP (with auth)', 'http://127.0.0.1:{PORT1}', + '--log --listen=http://:{PORT1} --proxy=http://hello:world@127.0.0.1:{PORT2}', + '--log --listen=http://hello:world@127.0.0.1:{PORT2}') + + test_naive('HTTPa-HTTPb,HTTPc (chaining with remote loop)', 'http://127.0.0.1:{PORT1}', + '--log --listen=http://:{PORT2}', + '--log --listen=http://:{PORT1} --proxy=http://127.0.0.1:{PORT2},http://127.0.0.1:{PORT2}') + + test_naive('HTTPa-HTTPb,HTTPc (chaining with multiple auth)', 'http://127.0.0.1:{PORT1}', + '--log --listen=http://hello:world2@127.0.0.1:{PORT2}', + '--log --listen=http://hello:world3@127.0.0.1:{PORT3}', + '--log --listen=http://127.0.0.1:{PORT1} --proxy=http://hello:world2@127.0.0.1:{PORT2},http://hello:world3@127.0.0.1:{PORT3}') -def allocate_port_number(): - global port - port += 1 - if port > 60000: - port = 10000 - return port +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--naive', required=True) + parser.add_argument('--rootfs') + parser.add_argument('--target_cpu') + parser.add_argument('--server_protocol', choices=['http', 'https'], default='https') + parser.add_argument('--skip-default-port-tests', action='store_true', + help='skip the four cases that require port 1080') + options = parser.parse_args() + options.naive = str(Path(options.naive).resolve()) + if options.rootfs: + options.rootfs = str(Path(options.rootfs).resolve()) + started = time.monotonic() + try: + with tempfile.TemporaryDirectory(prefix='naive-basic-') as tmp: + directory = Path(tmp) + with origin_server(options.server_protocol, directory) as origin_port: + tests = BasicTests(options, directory, origin_port) + tests.request() + run_tests(tests.test_naive) + print(f'{options.server_protocol}: {tests.passed} passed, ' + f'{tests.skipped} skipped in {time.monotonic() - started:.2f}s') + return 0 + except (TestFailure, OSError, subprocess.TimeoutExpired) as error: + print(f'** TEST FAIL: {error}', file=sys.stderr) + return 1 -def test_naive_once(proxy, *args, **kwargs): - port_map = {} - - class PortDict(dict): - def __init__(self, port_map): - self._port_map = port_map - - def __getitem__(self, key): - if key.startswith('PORT'): - if key not in self._port_map: - self._port_map[key] = str(allocate_port_number()) - return self._port_map[key] - return key - port_dict = PortDict(port_map) - - proxy = proxy.format_map(port_dict) - - config_file = kwargs.get('config_file') - config_content = kwargs.get('config_content') - if config_content is not None: - config_content = config_content.format_map(port_dict) - print(f"Writing {repr(config_content)} to {config_file}") - with open(config_file, 'w') as f: - f.write('{') - f.write(config_content) - f.write('}') - - naive_procs = [] - - def cleanup(): - if config_file is not None: - os.remove(config_file) - for naive_proc in naive_procs: - print('terminate pid', naive_proc.pid) - naive_proc.terminate() - - for args_instance in args: - naive_args = args_instance.format_map(port_dict).split() - naive_proc = start_naive(naive_args, config_file) - if naive_proc == 'Failed to listen': - cleanup() - return 'Failed to listen' - if not naive_proc: - cleanup() - return False - naive_procs.append(naive_proc) - - result = test_https_server(HTTPS_SERVER_HOSTNAME, HTTP_SERVER_PORT, proxy) - - cleanup() - - return result - - -def test_naive(label, proxy, *args, **kwargs): - RETRIES = 5 - result = None - if argv.target_cpu == 'arm' and not label.startswith('Default'): - # Arm tests are too slow in qemu-user - # due to https://www.openwall.com/lists/musl/2017/06/15/9 - print('** SKIP TEST:', label, end='\n\n') - return - - for i in range(RETRIES): - result = test_naive_once(proxy, *args, **kwargs) - if result == 'Failed to listen': - result = False - print('Retrying...') - time.sleep(1) - continue - break - if result is True: - print('** TEST PASS:', label, end='\n\n') - else: - print('** TEST FAIL:', label, end='\n\n') - sys.exit(1) - - -test_naive('Default config', 'socks5h://127.0.0.1:1080', - '--log') - -test_naive('Default config file', 'socks5h://127.0.0.1:{PORT1}', - '', - config_content='"listen":"socks://127.0.0.1:{PORT1}","log":""', - config_file='config.json') - -test_naive('Custom config file', 'socks5h://127.0.0.1:{PORT1}', - 'custom.json', - config_content='"listen":"socks://127.0.0.1:{PORT1}","log":""', - config_file='custom.json') - -test_naive('Multiple listens - command line', 'socks5h://127.0.0.1:{PORT1}', - '--log --listen=socks://:{PORT1} --listen=http://:{PORT2}') - -test_naive('Multiple listens - command line', 'http://127.0.0.1:{PORT2}', - '--log --listen=socks://:{PORT1} --listen=http://:{PORT2}') - -test_naive('Multiple listens - config file', 'socks5h://127.0.0.1:{PORT1}', - 'multiple-listen.json', - config_content='"listen":["socks://:{PORT1}", "http://:{PORT2}"],"log":""', - config_file='multiple-listen.json') - -test_naive('Multiple listens - config file', 'http://127.0.0.1:{PORT2}', - 'multiple-listen.json', - config_content='"listen":["socks://:{PORT1}", "http://:{PORT2}"],"log":""', - config_file='multiple-listen.json') - -test_naive('Multiple proxies - command line', 'socks5h://127.0.0.1:{PORT1}', - '--log --listen=socks://:{PORT1} --listen=socks://:{PORT2} --proxy=http://127.0.0.1:{PORT3} --proxy=http://127.0.0.1:{PORT4}', - '--log --listen=http://:{PORT3} --listen=http://:{PORT4} --proxy=socks://127.0.0.1:{PORT5}', - '--log --listen=socks://:{PORT5}') - -test_naive('Multiple proxies - command line', 'socks5h://127.0.0.1:{PORT2}', - '--log --listen=socks://:{PORT1} --listen=socks://:{PORT2} --proxy=http://127.0.0.1:{PORT3} --proxy=http://127.0.0.1:{PORT4}', - '--log --listen=http://:{PORT3} --listen=http://:{PORT4} --proxy=socks://127.0.0.1:{PORT5}', - '--log --listen=socks://:{PORT5}') - -test_naive('Multiple proxies - different auth', 'socks5h://127.0.0.1:{PORT1}', - '--log --listen=socks://:{PORT1} --listen=socks://:{PORT2} --proxy=http://user1:pass1@127.0.0.1:{PORT3} --proxy=http://user2:pass2@127.0.0.1:{PORT3}', - '--log --listen=http://user1:pass1@127.0.0.1:{PORT3}') - -test_naive('Multiple proxies - different auth', 'socks5h://127.0.0.1:{PORT2}', - '--log --listen=socks://:{PORT1} --listen=socks://:{PORT2} --proxy=http://user1:pass1@127.0.0.1:{PORT3} --proxy=http://user2:pass2@127.0.0.1:{PORT3}', - '--log --listen=http://user2:pass2@127.0.0.1:{PORT3}') - -test_naive('Trivial - listen scheme only', 'socks5h://127.0.0.1:1080', - '--log --listen=socks://') - -test_naive('Trivial - listen no host', 'socks5h://127.0.0.1:{PORT1}', - '--log --listen=socks://:{PORT1}') - -test_naive('Trivial - listen no port', 'socks5h://127.0.0.1:1080', - '--log --listen=socks://127.0.0.1') - -test_naive('Trivial - auth', 'socks5h://user:pass@127.0.0.1:{PORT1}', - '--log --listen=socks://user:pass@127.0.0.1:{PORT1}') - -test_naive('Trivial - auth with special chars', 'socks5h://user:^@127.0.0.1:{PORT1}', - '--log --listen=socks://user:^@127.0.0.1:{PORT1}') - -test_naive('Trivial - auth with special chars', 'socks5h://^:^@127.0.0.1:{PORT1}', - '--log --listen=socks://^:^@127.0.0.1:{PORT1}') - -test_naive('Trivial - auth with empty pass', 'socks5h://user:@127.0.0.1:{PORT1}', - '--log --listen=socks://user:@127.0.0.1:{PORT1}') - -test_naive('SOCKS-SOCKS', 'socks5h://127.0.0.1:{PORT1}', - '--log --listen=socks://:{PORT1} --proxy=socks://127.0.0.1:{PORT2}', - '--log --listen=socks://:{PORT2}') - -test_naive('SOCKS-SOCKS - proxy no port', 'socks5h://127.0.0.1:{PORT1}', - '--log --listen=socks://:{PORT1} --proxy=socks://127.0.0.1', - '--log --listen=socks://:1080') - -test_naive('SOCKS-HTTP', 'socks5h://127.0.0.1:{PORT1}', - '--log --listen=socks://:{PORT1} --proxy=http://127.0.0.1:{PORT2}', - '--log --listen=http://:{PORT2}') - -test_naive('HTTP-HTTP', 'http://127.0.0.1:{PORT1}', - '--log --listen=http://:{PORT1} --proxy=http://127.0.0.1:{PORT2}', - '--log --listen=http://:{PORT2}') - -test_naive('HTTP-SOCKS', 'http://127.0.0.1:{PORT1}', - '--log --listen=http://:{PORT1} --proxy=socks://127.0.0.1:{PORT2}', - '--log --listen=socks://:{PORT2}') - -test_naive('SOCKS-SOCKS-SOCKS', 'socks5h://127.0.0.1:{PORT1}', - '--log --listen=socks://:{PORT1} --proxy=socks://127.0.0.1:{PORT2}', - '--log --listen=socks://:{PORT2} --proxy=socks://127.0.0.1:{PORT3}', - '--log --listen=socks://:{PORT3}') - -test_naive('SOCKS-HTTP-SOCKS', 'socks5h://127.0.0.1:{PORT1}', - '--log --listen=socks://:{PORT1} --proxy=http://127.0.0.1:{PORT2}', - '--log --listen=http://:{PORT2} --proxy=socks://127.0.0.1:{PORT3}', - '--log --listen=socks://:{PORT3}') - -test_naive('HTTP-SOCKS-HTTP', 'http://127.0.0.1:{PORT1}', - '--log --listen=http://:{PORT1} --proxy=socks://127.0.0.1:{PORT2}', - '--log --listen=socks://:{PORT2} --proxy=http://127.0.0.1:{PORT3}', - '--log --listen=http://:{PORT3}') - -test_naive('HTTP-HTTP-HTTP', 'http://127.0.0.1:{PORT1}', - '--log --listen=http://:{PORT1} --proxy=http://127.0.0.1:{PORT2}', - '--log --listen=http://:{PORT2} --proxy=http://127.0.0.1:{PORT3}', - '--log --listen=http://:{PORT3}') - -test_naive('HTTP-HTTP (with auth)', 'http://127.0.0.1:{PORT1}', - '--log --listen=http://:{PORT1} --proxy=http://hello:world@127.0.0.1:{PORT2}', - '--log --listen=http://hello:world@127.0.0.1:{PORT2}') - -test_naive('HTTPa-HTTPb,HTTPc (chaining with remote loop)', 'http://127.0.0.1:{PORT1}', - '--log --listen=http://:{PORT2}', - '--log --listen=http://:{PORT1} --proxy=http://127.0.0.1:{PORT2},http://127.0.0.1:{PORT2}') - -test_naive('HTTPa-HTTPb,HTTPc (chaining with multiple auth)', 'http://127.0.0.1:{PORT1}', - '--log --listen=http://hello:world2@127.0.0.1:{PORT2}', - '--log --listen=http://hello:world3@127.0.0.1:{PORT3}', - '--log --listen=http://127.0.0.1:{PORT1} --proxy=http://hello:world2@127.0.0.1:{PORT2},http://hello:world3@127.0.0.1:{PORT3}') +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/ssl_cert_file.py b/tests/ssl_cert_file.py new file mode 100644 index 0000000000..0d4e93cca1 --- /dev/null +++ b/tests/ssl_cert_file.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Test SSL_CERT_FILE and SSL_CERT_DIR with HTTPS and QUIC (no root required). + +Usage: python3 tests/ssl_cert_file.py --naive=src/out/Release/naive +Requires openssl, curl, and Caddy with the naive forwardproxy plugin. Caddy +defaults to tests/caddy; use --caddy to select another binary. All traffic is +local, and certificates, configs, and logs live in a temporary directory. +""" + +import argparse +import contextlib +import http.server +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import tempfile +import threading +import time + + +TESTS_DIR = Path(__file__).resolve().parent +CERTS_DIR = TESTS_DIR.parent / 'src/net/tools/quic/certs' +PAYLOAD = b'Local CA integration test\n' * 1024 + + +def run(command, **kwargs): + result = subprocess.run(command, capture_output=True, timeout=30, **kwargs) + if result.returncode: + raise RuntimeError( + f'{command} exited with {result.returncode}:\n' + f'{result.stderr.decode(errors="replace")}') + return result + + +def generate_certs(directory): + # The Chromium script uses relative paths and removes ./out. Copy it and + # its configs so it runs exclusively inside our temporary directory. + directory.mkdir() + for name in ('generate-certs.sh', 'ca.cnf', 'leaf.cnf'): + shutil.copyfile(CERTS_DIR / name, directory / name) + env = os.environ.copy() + # These variables override the defaults in Chromium's OpenSSL configs. + for name in ('CA_DIR', 'KEY_SIZE', 'ALGO', 'CERT_TYPE', 'CA_NAME', + 'SUBJECT_NAME'): + env.pop(name, None) + run(['sh', 'generate-certs.sh'], cwd=directory, env=env) + return directory / 'out' + + +def unused_port(udp=False): + # QUIC needs a port available for both TCP and UDP. Hold both sockets + # while checking; the short gap before Caddy binds is unavoidable. + for _ in range(100): + with socket.socket() as tcp: + tcp.bind(('127.0.0.1', 0)) + port = tcp.getsockname()[1] + if port < 1024: + continue + if udp: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + try: + sock.bind(('127.0.0.1', port)) + except OSError: + continue + return port + raise RuntimeError('Could not allocate an unprivileged loopback port') + + +def netlog_events(netlog): + # Naive exits on SIGTERM without closing the NetLog JSON document. Each + # complete event is on its own line, so read those instead of the wrapper. + for line in netlog.read_text().splitlines(): + try: + event = json.loads(line.rstrip(',')) + except json.JSONDecodeError: + continue + if isinstance(event, dict) and 'type' in event: + yield event + + +@contextlib.contextmanager +def running(command, directory, env, label, startup_marker): + log = directory / f'{label}.log' + with log.open('w') as output: + proc = subprocess.Popen(command, cwd=directory, env=env, + stdout=output, stderr=subprocess.STDOUT) + try: + deadline = time.monotonic() + 10 + while True: + if proc.poll() is not None: + raise RuntimeError(f'{label} exited with {proc.returncode}') + if startup_marker in log.read_text(errors='replace'): + break + if time.monotonic() >= deadline: + raise RuntimeError(f'{label} did not start within 10 seconds') + time.sleep(0.05) + yield proc + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + + +class OriginHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Content-Length', str(len(PAYLOAD))) + self.end_headers() + self.wfile.write(PAYLOAD) + + def log_message(self, *args): + pass + + +def test_protocol(protocol, naive, caddy, directory, certs, origin_port): + directory.mkdir() + root_cert = certs / '2048-sha256-root.pem' + empty_ca_file = directory / 'empty-ca.pem' + empty_ca_file.write_text('') + empty_ca_dir = directory / 'empty-ca-dir' + empty_ca_dir.mkdir() + trusted_ca_dir = directory / 'trusted-ca-dir' + trusted_ca_dir.mkdir() + # Only the CA belongs in this directory, not the leaf or any private keys. + shutil.copyfile(root_cert, trusted_ca_dir / 'local-ca.pem') + cases = ( + ('untrusted', empty_ca_file, empty_ca_dir, False), + ('SSL_CERT_FILE', root_cert, empty_ca_dir, True), + ('SSL_CERT_DIR', empty_ca_file, trusted_ca_dir, True), + ('SSL_CERT_DIR-list', empty_ca_file, + f'{empty_ca_dir}{os.pathsep}{trusted_ca_dir}', True), + ) + caddy_port = unused_port(udp=protocol == 'quic') + config = directory / 'Caddyfile' + # Restrict the server to the requested transport so QUIC cannot pass by + # falling back to HTTPS. No admin listener, port 80 redirect, ACME, or + # system trust installation is needed. Only the local origin is allowed. + protocols = 'h1 h2' if protocol == 'https' else 'h3' + config.write_text(f'''{{ + admin off + auto_https off + persist_config off + servers {{ + protocols {protocols} + }} +}} +https://127.0.0.1:{caddy_port} {{ + bind 127.0.0.1 + tls "{certs / 'leaf_cert.pem'}" "{certs / 'leaf_cert.key'}" + route {{ + forward_proxy {{ + basic_auth user pass + ports {origin_port} + acl {{ + allow 127.0.0.1 + deny all + }} + }} + respond "Caddy local CA test" + }} +}} +''') + caddy_env = os.environ.copy() + caddy_env['XDG_DATA_HOME'] = str(directory / 'data') + caddy_env['XDG_CONFIG_HOME'] = str(directory / 'config') + with running([caddy, 'run', '--config', str(config), '--adapter', 'caddyfile'], + directory, caddy_env, 'caddy', 'serving initial configuration'): + for label, ca_file, ca_dirs, trusted in cases: + listen_port = unused_port() + netlog = directory / f'{label}-netlog.json' + env = os.environ.copy() + # Override both variables, including system-default fallbacks, so + # success can only come from the trust source this case supplies. + env['SSL_CERT_FILE'] = str(ca_file) + env['SSL_CERT_DIR'] = str(ca_dirs) + env['TEST_MARK_STARTUP'] = 'yes' + command = [naive, f'--listen=socks://127.0.0.1:{listen_port}', + f'--proxy={protocol}://user:pass@127.0.0.1:{caddy_port}', + '--log', f'--log-net-log={netlog}'] + with running(command, directory, env, label, 'TEST_MARK_STARTUP') as proc: + # --disable ignores ~/.curlrc; --noproxy overrides NO_PROXY. + # The origin uses HTTP: TLS verification under test belongs + # to naive's connection to Caddy, not to curl. + result = subprocess.run( + ['curl', '--disable', '--silent', '--show-error', '--fail', + '--max-time', '10', '--noproxy', '', '--proxy', + f'socks5h://127.0.0.1:{listen_port}', + f'http://127.0.0.1:{origin_port}/'], + capture_output=True, timeout=15) + if proc.poll() is not None: + raise RuntimeError(f'{protocol}/{label}: naive exited') + if trusted: + if result.returncode or result.stdout != PAYLOAD: + raise RuntimeError( + f'{protocol}/{label}: failed with trusted CA ' + f'(curl exit {result.returncode}):\n' + f'{result.stderr.decode(errors="replace")}') + else: + if result.returncode == 0: + raise RuntimeError(f'{protocol}: accepted an untrusted CA') + # A timeout or connection failure alone cannot prove that + # certificate verification ran. Require its authority error. + if not any(event.get('params', {}).get('net_error') == -202 + for event in netlog_events(netlog)): + raise RuntimeError( + f'{protocol}: expected ERR_CERT_AUTHORITY_INVALID ' + f'with empty CA sources, curl exit {result.returncode}') + print(f'PASS: {protocol} {label}', flush=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--naive', required=True, type=Path) + parser.add_argument('--caddy', default=TESTS_DIR / 'caddy', type=Path) + args = parser.parse_args() + naive, caddy = str(args.naive.resolve()), str(args.caddy.resolve()) + for binary in (naive, caddy): + if not os.access(binary, os.X_OK): + parser.error(f'Not an executable: {binary}') + for tool in ('openssl', 'curl'): + if shutil.which(tool) is None: + parser.error(f'Required executable not found: {tool}') + + with tempfile.TemporaryDirectory(prefix='naive-ssl-cert-file-') as tmp: + directory = Path(tmp) + try: + certs = generate_certs(directory / 'certs') + with http.server.ThreadingHTTPServer( + ('127.0.0.1', 0), OriginHandler) as origin: + thread = threading.Thread(target=origin.serve_forever, daemon=True) + thread.start() + try: + for protocol in ('https', 'quic'): + test_protocol(protocol, naive, caddy, directory / protocol, + certs, origin.server_port) + finally: + origin.shutdown() + thread.join() + except Exception as error: + print(f'FAIL: {error}', file=sys.stderr) + for log in sorted(directory.glob('*/*.log')): + print(f'\n--- {log.parent.name}/{log.name} ---', file=sys.stderr) + print(log.read_text(errors='replace'), file=sys.stderr) + for netlog in sorted(directory.glob('*/*-netlog.json')): + print(f'\n--- {netlog.parent.name}/{netlog.name} verification/errors ---', + file=sys.stderr) + for event in netlog_events(netlog): + params = event.get('params', {}) + if ('cert_status' in params or + any('error' in key and value for key, value in params.items())): + print({key: value for key, value in params.items() + if 'error' in key or key in ( + 'details', 'cert_status', 'is_issued_by_known_root')}, + file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main())