penguin/tinyproxy

An L4 proxy designed to act as a tiny transparent shim

commit 7597a697a32b024fe6a303444e53f87531165134

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-05-27T07:51:24Z
subjectAdded keep_live for tcp_route
commit 7597a697a32b024fe6a303444e53f87531165134
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-05-27T07:51:24Z

    Added keep_live for tcp_route
---
 .github/workflows/cmake-multi-platform.yml |   1 +
 Dockerfile                                 |   2 +-
 tcp_route.c                                |  30 +++++
 tests/run_tests.py                         |   2 +
 tests/test_tcp_connect_timeout.py          |  13 +-
 tests/test_tcp_keep_alive.py               | 202 +++++++++++++++++++++++++++++
 6 files changed, 243 insertions(+), 7 deletions(-)

diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml
index ad40c20..120174c 100644
--- a/.github/workflows/cmake-multi-platform.yml
+++ b/.github/workflows/cmake-multi-platform.yml
@@ -30,6 +30,7 @@ jobs:
             libevent-dev \
             libevent-static \
             python3 \
+            strace \
             haproxy
 
       - &build_static
diff --git a/Dockerfile b/Dockerfile
index e612879..4be0800 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,7 +11,7 @@ COPY [ "*.c", "*.h", "*.conf", "Makefile", "/src" ]
 RUN make all STATIC=1
 
 RUN --mount=type=cache,target=/var/cache/apk,sharing=locked \
-	apk add --update-cache haproxy
+	apk add --update-cache haproxy strace
 
 COPY tests ./tests
 RUN make test STATIC=1
diff --git a/tcp_route.c b/tcp_route.c
index a249930..bf89054 100644
--- a/tcp_route.c
+++ b/tcp_route.c
@@ -124,6 +124,16 @@ static void set_idle_timeouts(conn_t *conn, const struct route *r)
 	bufferevent_set_timeouts(conn->upstream, &idle_timeout, &idle_timeout);
 }
 
+static int set_socket_keepalive(evutil_socket_t fd, const struct route *r)
+{
+	int v = r->opts.keep_alive ? 1 : 0;
+
+	if (setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, (const char *)&v, sizeof(v)) < 0) {
+		return -errno;
+	}
+
+	return 0;
+}
 
 static void event_cb(struct bufferevent *bev, short events, void *arg) {
 	conn_t *conn = arg;
@@ -177,6 +187,13 @@ static void worker_adopt_client_fd(struct worker *w, struct accepted_client *ac)
 		return;
 	}
 
+	int rc = set_socket_keepalive(ac->fd, r);
+	if (rc < 0) {
+		LOG_WARN("failed to enable client TCP keepalive",
+			"err", _LOGV(strerror(-rc))
+		);
+	}
+
 	bufferevent_setwatermark(conn->client, EV_READ, 0, BEV_READ_HIGH_WATER);
 	bufferevent_setwatermark(conn->upstream, EV_READ, 0, BEV_READ_HIGH_WATER);
 
@@ -226,6 +243,19 @@ static void worker_adopt_client_fd(struct worker *w, struct accepted_client *ac)
 		return;
 	}
 
+	if (r->opts.keep_alive) {
+		evutil_socket_t upstream_fd = bufferevent_getfd(conn->upstream);
+
+		if (upstream_fd >= 0) {
+			int rc = set_socket_keepalive(upstream_fd, r);
+			if (rc < 0) {
+				LOG_WARN("failed to enable upstream TCP keepalive",
+					"err", _LOGV(strerror(-rc))
+				);
+			}
+		}
+	}
+
 	if (r->opts.proxy_v2) {
 		struct sockaddr_in local_addr;
 		socklen_t local_len = sizeof(local_addr);
diff --git a/tests/run_tests.py b/tests/run_tests.py
index a48b5a3..1123e56 100644
--- a/tests/run_tests.py
+++ b/tests/run_tests.py
@@ -13,6 +13,7 @@ from .support import (
 from . import test_tcp_basic
 from . import test_tcp_idle_timeout
 from . import test_tcp_connect_timeout
+from . import test_tcp_keep_alive
 from . import test_tcp_stress
 from . import test_tcp_backpressure
 from . import test_udp_basic
@@ -35,6 +36,7 @@ STANDALONE_TEST_MODULES = [
 	test_udp_proxy_v2,
 	test_tcp_backpressure,
 	test_tcp_idle_timeout,
+	test_tcp_keep_alive,
 	test_tcp_connect_timeout,
 	test_udp_idle_timeout,
 ]
diff --git a/tests/test_tcp_connect_timeout.py b/tests/test_tcp_connect_timeout.py
index 7d754fe..c999256 100644
--- a/tests/test_tcp_connect_timeout.py
+++ b/tests/test_tcp_connect_timeout.py
@@ -10,6 +10,7 @@ from .support import (
 )
 
 
+CONNECT_TIMEOUT_PROXY_PORT = PROXY_PORT + 20
 BLACKHOLE_HOST = "192.0.2.1"
 BLACKHOLE_PORT = 65000
 
@@ -35,7 +36,7 @@ async def test_tcp_connect_timeout_closes_client_connection() -> None:
 		raise SkipTest("TINYPROXY_BIN is not set")
 
 	conf_text = (
-		f"{LISTEN_HOST}:{PROXY_PORT} "
+		f"{LISTEN_HOST}:{CONNECT_TIMEOUT_PROXY_PORT} "
 		f"{BLACKHOLE_HOST}:{BLACKHOLE_PORT} "
 		f"tcp connect_timeout=1,idle_timeout=30\n"
 	)
@@ -44,10 +45,10 @@ async def test_tcp_connect_timeout_closes_client_connection() -> None:
 		proxy_bin=proxy_bin,
 		conf_text=conf_text,
 		listen_host=LISTEN_HOST,
-		listen_port=PROXY_PORT,
+		listen_port=CONNECT_TIMEOUT_PROXY_PORT,
 		proto="tcp",
 	):
-		reader, writer = await asyncio.open_connection(LISTEN_HOST, PROXY_PORT)
+		reader, writer = await asyncio.open_connection(LISTEN_HOST, CONNECT_TIMEOUT_PROXY_PORT)
 
 		try:
 			start = time.monotonic()
@@ -94,7 +95,7 @@ async def test_tcp_connect_timeout_does_not_replace_idle_timeout_after_connect()
 	from .support import BACKEND_PORT
 
 	conf_text = (
-		f"{LISTEN_HOST}:{PROXY_PORT} "
+		f"{LISTEN_HOST}:{CONNECT_TIMEOUT_PROXY_PORT} "
 		f"{LISTEN_HOST}:{BACKEND_PORT} "
 		f"tcp connect_timeout=1,idle_timeout=2\n"
 	)
@@ -111,10 +112,10 @@ async def test_tcp_connect_timeout_does_not_replace_idle_timeout_after_connect()
 			proxy_bin=proxy_bin,
 			conf_text=conf_text,
 			listen_host=LISTEN_HOST,
-			listen_port=PROXY_PORT,
+			listen_port=CONNECT_TIMEOUT_PROXY_PORT,
 			proto="tcp",
 		):
-			reader, writer = await asyncio.open_connection(LISTEN_HOST, PROXY_PORT)
+			reader, writer = await asyncio.open_connection(LISTEN_HOST, CONNECT_TIMEOUT_PROXY_PORT)
 
 			try:
 				payload = b"connected path still works\n"
diff --git a/tests/test_tcp_keep_alive.py b/tests/test_tcp_keep_alive.py
new file mode 100644
index 0000000..4d2c5ac
--- /dev/null
+++ b/tests/test_tcp_keep_alive.py
@@ -0,0 +1,202 @@
+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_HOST}:{PROXY_PORT} "
+		f"{LISTEN_HOST}:{BACKEND_PORT} "
+		f"tcp 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_HOST}:{PROXY_PORT} "
+		f"{LISTEN_HOST}:{BACKEND_PORT} "
+		f"tcp 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),
+]