415 lines
18 KiB
Python
415 lines
18 KiB
Python
#!/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
|
|
from pathlib import Path
|
|
import queue
|
|
import shlex
|
|
import socket
|
|
import ssl
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
|
|
|
|
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
|
|
|
|
|
|
@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()
|
|
|
|
|
|
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)}')
|
|
|
|
@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:
|
|
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()
|
|
|
|
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)
|
|
|
|
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='')
|
|
|
|
def test_naive_once(self, proxy, *arguments, **kwargs):
|
|
allocated = set()
|
|
|
|
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)
|
|
|
|
|
|
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 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
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|