penguin/tinyproxy

An L4 proxy designed to act as a tiny transparent shim

tests/test_tcp_connect_timeout.py

raw ยท 3909 bytes

import asyncio
import os
import time

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


BLACKHOLE_HOST = "192.0.2.1"
BLACKHOLE_PORT = 65000


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


async def read_one_or_eof(reader: asyncio.StreamReader, timeout: float = 5.0) -> bytes:
	try:
		return await asyncio.wait_for(reader.read(1), timeout=timeout)
	except ConnectionResetError:
		return b""


async def test_tcp_connect_timeout_closes_client_connection() -> None:
	proxy_bin = os.environ.get("TINYPROXY_BIN")
	if not proxy_bin:
		raise SkipTest("TINYPROXY_BIN is not set")

	conf_text = (
		f"{LISTEN_HOST}:{PROXY_PORT} "
		f"{BLACKHOLE_HOST}:{BLACKHOLE_PORT} "
		f"tcp connect_timeout=1,idle_timeout=30\n"
	)

	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:
			start = time.monotonic()

			writer.write(b"hello before upstream connect\n")
			await writer.drain()

			got = await read_one_or_eof(reader, timeout=5.0)
			elapsed = time.monotonic() - start

			assert got == b"", (
				f"expected proxy to close client after connect timeout, got={got!r}"
			)

			assert elapsed >= 0.8, (
				f"connect timeout fired too early: elapsed={elapsed:.3f}s"
			)

			assert elapsed < 4.0, (
				f"connect timeout took too long: elapsed={elapsed:.3f}s"
			)
		finally:
			await close_writer(writer)


async def test_tcp_connect_timeout_does_not_replace_idle_timeout_after_connect() -> None:
	proxy_bin = os.environ.get("TINYPROXY_BIN")
	if not proxy_bin:
		raise SkipTest("TINYPROXY_BIN is not set")

	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()

	from .support import BACKEND_PORT

	conf_text = (
		f"{LISTEN_HOST}:{PROXY_PORT} "
		f"{LISTEN_HOST}:{BACKEND_PORT} "
		f"tcp connect_timeout=1,idle_timeout=2\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"connected path still works\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}"
				)

				await asyncio.sleep(1.2)

				payload = b"still alive after connect timeout duration\n"

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

				got = await asyncio.wait_for(
					reader.readexactly(len(payload)),
					timeout=3.0,
				)
				assert got == payload, (
					f"connection died as if connect_timeout remained active: "
					f"got={got!r} expected={payload!r}"
				)

				await asyncio.sleep(2.5)

				got = await read_one_or_eof(reader, timeout=3.0)
				assert got == b"", (
					f"expected idle timeout to close established connection, got={got!r}"
				)
			finally:
				await close_writer(writer)
	finally:
		backend_server.close()
		await backend_server.wait_closed()


TESTS = [
	(
		"test_tcp_connect_timeout_closes_client_connection",
		test_tcp_connect_timeout_closes_client_connection,
	),
	(
		"test_tcp_connect_timeout_does_not_replace_idle_timeout_after_connect",
		test_tcp_connect_timeout_does_not_replace_idle_timeout_after_connect,
	),
]