Improve tests

This commit is contained in:
klzgrad
2026-10-05 23:41:35 +08:00
parent 2655ebc38c
commit d6ba2a2e55
2 changed files with 653 additions and 311 deletions
+385 -311
View File
@@ -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())
+268
View File
@@ -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())