penguin/tinyproxy

An L4 proxy designed to act as a tiny transparent shim

tests/test_tcp_keep_alive.py

raw ยท 4415 bytes

import asyncio
import os
import platform
import shutil
import subprocess
import tempfile
from pathlib import Path

from .support import (
	LISTEN_HOST,
	BACKEND_PORT,
	PROXY_PORT,
	SkipTest,
	run_tinyproxy_with_conf,
)


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

			writer.write(data)
			await writer.drain()
	finally:
		writer.close()
		await writer.wait_closed()


async def close_writer(writer: asyncio.StreamWriter) -> None:
	try:
		writer.close()
		await writer.wait_closed()
	except ConnectionResetError:
		pass


async def wait_for_tcp_listen(host: str, port: int, timeout: float = 5.0) -> None:
	deadline = asyncio.get_running_loop().time() + timeout
	last_error = None

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

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


async def test_tcp_keep_alive_roundtrip() -> None:

	proxy_bin = os.environ.get("TINYPROXY_BIN")
	if not proxy_bin:
		raise SkipTest("TINYPROXY_BIN is not set")

	conf_text = (
		f"listen"
		f" tcp {LISTEN_HOST}:{PROXY_PORT}"
		f" tcp {LISTEN_HOST}:{BACKEND_PORT}"
		f" keep_alive,idle_timeout=5\n"
	)

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

	try:
		async with run_tinyproxy_with_conf(
			proxy_bin=proxy_bin,
			conf_text=conf_text,
			listen_host=LISTEN_HOST,
			listen_port=PROXY_PORT,
			proto="tcp",
		):
			reader, writer = await asyncio.open_connection(LISTEN_HOST, PROXY_PORT)

			try:
				payload = b"keep-alive smoke test\n"

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

				got = await asyncio.wait_for(
					reader.readexactly(len(payload)),
					timeout=3.0,
				)

				assert got == payload, (
					f"roundtrip mismatch: got={got!r} expected={payload!r}"
				)
			finally:
				await close_writer(writer)
	finally:
		backend_server.close()
		await backend_server.wait_closed()


async def test_tcp_keep_alive_sets_socket_option() -> None:
	if platform.system() != "Linux":
		raise SkipTest("strace keep_alive test only runs on Linux")

	strace_bin = shutil.which("strace")
	if not strace_bin:
		raise SkipTest("strace is not installed")

	proxy_bin = os.environ.get("TINYPROXY_BIN")
	if not proxy_bin:
		raise SkipTest("TINYPROXY_BIN is not set")

	conf_text = (
		f"listen"
		f" tcp {LISTEN_HOST}:{PROXY_PORT}"
		f" tcp {LISTEN_HOST}:{BACKEND_PORT}"
		f" keep_alive,idle_timeout=5\n"
	)

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

	with tempfile.TemporaryDirectory() as td:
		td_path = Path(td)
		conf_path = td_path / "tinyproxy.conf"
		trace_path = td_path / "strace.log"

		conf_path.write_text(conf_text)

		proxy = subprocess.Popen(
			[
				strace_bin,
				"-f",
				"-e",
				"trace=setsockopt",
				"-o",
				str(trace_path),
				proxy_bin,
				"-c",
				str(conf_path),
			],
			stdout=subprocess.PIPE,
			stderr=subprocess.PIPE,
			text=True,
		)

		try:
			await wait_for_tcp_listen(LISTEN_HOST, PROXY_PORT)

			reader, writer = await asyncio.open_connection(LISTEN_HOST, PROXY_PORT)

			try:
				payload = b"trigger upstream connect\n"

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

				got = await asyncio.wait_for(
					reader.readexactly(len(payload)),
					timeout=3.0,
				)

				assert got == payload, (
					f"roundtrip mismatch: got={got!r} expected={payload!r}"
				)
			finally:
				await close_writer(writer)
		finally:
			proxy.terminate()

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

			backend_server.close()
			await backend_server.wait_closed()

		trace_text = trace_path.read_text(errors="replace")

		keepalive_count = trace_text.count("SO_KEEPALIVE")

		assert keepalive_count >= 2, (
			"expected SO_KEEPALIVE to be enabled on both client and upstream sockets; "
			f"found {keepalive_count} calls\n\nstrace output:\n{trace_text}"
		)


TESTS = [
	("test_tcp_keep_alive_roundtrip", test_tcp_keep_alive_roundtrip),
	("test_tcp_keep_alive_sets_socket_option", test_tcp_keep_alive_sets_socket_option),
]