penguin/tinyproxy

An L4 proxy designed to act as a tiny transparent shim

tests/test_proxy.py

raw ยท 6055 bytes

#!/usr/bin/env python3

import asyncio
import os
import signal
import subprocess
import sys
import time
import tempfile

try:
	import resource
except ImportError:
	resource = None

LISTEN_HOST = "127.0.0.1"
PROXY_PORT = 31232
BACKEND_PORT = 41232

def raise_fd_limit(wanted: int) -> None:
	if resource is None:
		return

	soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)

	if soft >= wanted:
		return

	new_soft = min(wanted, hard)

	try:
		resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, hard))
		print(f"raised RLIMIT_NOFILE soft limit: {soft} -> {new_soft}")
	except OSError as exc:
		print(f"warning: failed to raise fd limit: {exc}", file=sys.stderr)
		print(f"current RLIMIT_NOFILE soft={soft} hard={hard}", file=sys.stderr)


async def wait_for_port(host: str, port: int, timeout: float = 5.0) -> None:
	deadline = time.monotonic() + timeout

	while time.monotonic() < deadline:
		try:
			reader, writer = await asyncio.open_connection(host, port)
			writer.close()
			await writer.wait_closed()
			return
		except OSError:
			await asyncio.sleep(0.05)

	raise RuntimeError(f"timed out waiting for {host}:{port}")


async def echo_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
	try:
		while True:
			data = await reader.read(65536)
			if not data:
				return

			writer.write(data)
			await writer.drain()

	except (ConnectionResetError, BrokenPipeError):
		return

	finally:
		writer.close()

		try:
			await writer.wait_closed()
		except OSError:
			pass


async def recv_exact(reader: asyncio.StreamReader, size: int) -> bytes:
	chunks = []
	remaining = size

	while remaining > 0:
		chunk = await reader.read(min(65536, remaining))
		if not chunk:
			break

		chunks.append(chunk)
		remaining -= len(chunk)

	return b"".join(chunks)


async def proxy_roundtrip(payload: bytes, timeout: float = 10.0) -> bytes:
	reader, writer = await asyncio.open_connection(LISTEN_HOST, PROXY_PORT)

	try:
		writer.write(payload)
		await writer.drain()

		return await asyncio.wait_for(
			recv_exact(reader, len(payload)),
			timeout=timeout,
		)

	finally:
		writer.close()

		try:
			await writer.wait_closed()
		except OSError:
			pass


async def test_small_roundtrip() -> None:
	payload = b"hello through proxy\n"
	got = await proxy_roundtrip(payload)
	assert got == payload, f"small roundtrip mismatch: {got!r}"


async def test_large_roundtrip() -> None:
	payload = b"0123456789abcdef" * 131072  # 2 MiB
	got = await proxy_roundtrip(payload, timeout=30.0)
	assert got == payload, f"large roundtrip mismatch: got {len(got)} bytes"


async def test_many_sequential_connections(count: int) -> None:
	for i in range(count):
		payload = f"message-{i}\n".encode()
		got = await proxy_roundtrip(payload)
		assert got == payload, f"sequential connection {i} failed"


async def run_one_concurrent(i: int, payload_size: int, sem: asyncio.Semaphore) -> None:
	async with sem:
		prefix = f"worker-{i}-".encode()
		payload = (prefix * ((payload_size // len(prefix)) + 1))[:payload_size]

		got = await proxy_roundtrip(payload, timeout=30.0)

		if got != payload:
			raise AssertionError(
				f"worker {i} mismatch: expected {len(payload)} bytes, got {len(got)}"
			)


async def test_concurrent_connections(
	total: int,
	concurrency: int,
	payload_size: int,
) -> None:
	sem = asyncio.Semaphore(concurrency)

	tasks = [
		asyncio.create_task(run_one_concurrent(i, payload_size, sem))
		for i in range(total)
	]

	done = 0

	for task in asyncio.as_completed(tasks):
		await task
		done += 1

		if done % 1000 == 0:
			print(f"  completed {done}/{total}")


async def main_async(proxy_bin: str) -> int:
	fd_limit = int(os.environ.get("FD_LIMIT", "65535"))
	raise_fd_limit(fd_limit)

	backend_server = await asyncio.start_server(
		echo_handler,
		LISTEN_HOST,
		BACKEND_PORT,
		backlog=4096,
	)

	conf_path = None
	proxy = None

	try:
		fd, conf_path = tempfile.mkstemp(suffix=".conf", text=True)
		with os.fdopen(fd, "w") as f:
			f.write(f"{LISTEN_HOST}:{PROXY_PORT} {LISTEN_HOST}:{BACKEND_PORT} tcp\n")

		proxy = subprocess.Popen(
			[proxy_bin, "-c", conf_path],
			stdout=subprocess.PIPE,
			stderr=subprocess.PIPE,
			text=True,
		)

		await asyncio.sleep(0.2)

		if proxy.poll() is not None:
			stderr = proxy.stderr.read() if proxy.stderr else ""
			raise RuntimeError(
				f"proxy exited early with code {proxy.returncode}\n{stderr}"
			)
			await wait_for_port(LISTEN_HOST, BACKEND_PORT)
			await wait_for_port(LISTEN_HOST, PROXY_PORT)

		print("running test_small_roundtrip...")
		await test_small_roundtrip()
		print("ok test_small_roundtrip")

		print("running test_large_roundtrip...")
		await test_large_roundtrip()
		print("ok test_large_roundtrip")

		print("running test_many_sequential_connections 1000...")
		await test_many_sequential_connections(1000)
		print("ok test_many_sequential_connections")

		total = int(os.environ.get("TOTAL", "10000"))
		concurrency = int(os.environ.get("CONCURRENCY", "10000"))
		payload_size = int(os.environ.get("PAYLOAD_SIZE", "1024"))

		print(
			f"running test_concurrent_connections "
			f"total={total} concurrency={concurrency} payload_size={payload_size}..."
		)

		await test_concurrent_connections(
			total=total,
			concurrency=concurrency,
			payload_size=payload_size,
		)

		print("ok test_concurrent_connections")
		print("all tests passed")
		return 0

	finally:
		if proxy is not None:
			proxy.terminate()

			try:
				proxy.wait(timeout=2.0)
			except subprocess.TimeoutExpired:
				proxy.kill()
				proxy.wait(timeout=2.0)

		backend_server.close()
		await backend_server.wait_closed()

		if proxy is not None:
			stderr = proxy.stderr.read() if proxy.stderr else ""
			if stderr:
				print("\nproxy stderr:")
				print(stderr)

		if conf_path is not None:
			try:
				os.unlink(conf_path)
			except FileNotFoundError:
				pass


def main() -> int:
	if len(sys.argv) != 2:
		print(f"usage: {sys.argv[0]} tinyproxy", file=sys.stderr)
		return 2

	return asyncio.run(main_async(sys.argv[1]))


if __name__ == "__main__":
	raise SystemExit(main())