penguin/tinyproxy

An L4 proxy designed to act as a tiny transparent shim

commit b1e45e55a003dc011ad90abe3661a8a8406a72ac

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-05-25T01:50:47Z
subjectBasic tcp route
commit b1e45e55a003dc011ad90abe3661a8a8406a72ac
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-05-25T01:50:47Z

    Basic tcp route
---
 .gitignore          |   5 ++
 Makefile            |  73 +++++++++++++++
 README.md           |   9 ++
 file_conf.c         | 225 ++++++++++++++++++++++++++++++++++++++++++++++
 file_conf.h         |  12 +++
 route.h             |  34 +++++++
 tcp_route.c         | 220 +++++++++++++++++++++++++++++++++++++++++++++
 tcp_route.h         |  12 +++
 tests/test_proxy.py | 250 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 tinyproxy.c         | 146 ++++++++++++++++++++++++++++++
 tinyproxy.conf      |   5 ++
 11 files changed, 991 insertions(+)

diff --git a/.gitignore b/.gitignore
index 845cda6..280e3f0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,8 @@
+bin/
+build/
+libevent2/
+*.log
+
 # Prerequisites
 *.d
 
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..6fd8d6e
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,73 @@
+export
+
+CC ?= cc
+AR := /usr/bin/ar
+RANLIB := /usr/bin/ranlib
+STRIP ?= strip
+
+PROJECT_ROOT := $(CURDIR)
+BIN_DIR := $(CURDIR)/bin
+
+SRC := tcp_route.c \
+       file_conf.c \
+       tinyproxy.c
+
+BIN := $(BIN_DIR)/tinyproxy
+
+BUILD_DIR := $(PROJECT_ROOT)/build
+
+LIBEVENT_SRC := $(PROJECT_ROOT)/libevent2
+LIBEVENT_PREFIX := $(BUILD_DIR)/libevent-install
+LIBEVENT_CORE_A := $(LIBEVENT_PREFIX)/lib/libevent_core.a
+
+CFLAGS ?= -Os -Wall -Wextra -ffunction-sections -fdata-sections
+CPPFLAGS += -I$(LIBEVENT_PREFIX)/include
+LDFLAGS += -L$(LIBEVENT_PREFIX)/lib
+
+ifeq ($(shell uname),Darwin)
+LDFLAGS += -Wl,-dead_strip
+else
+LDFLAGS += -Wl,--gc-sections
+endif
+
+LDLIBS += -levent_core
+
+all: $(BIN)
+
+$(BIN): $(SRC) $(LIBEVENT_CORE_A)
+	mkdir -p $(BIN_DIR)
+	$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRC) $(LDFLAGS) $(LDLIBS)
+
+strip: $(BIN)
+	$(STRIP) $(BIN)
+
+$(LIBEVENT_CORE_A):
+	cd $(LIBEVENT_SRC) && \
+		AR=$(AR) RANLIB=$(RANLIB) ./configure \
+		--prefix="$(LIBEVENT_PREFIX)" \
+		--disable-openssl \
+		--disable-samples \
+		--disable-libevent-regress \
+		--disable-debug-mode \
+		--disable-malloc-replacement \
+		--disable-thread-support \
+		--disable-dependency-tracking \
+		--enable-static \
+		--disable-shared
+	AR=$(AR) RANLIB=$(RANLIB) $(MAKE) -C $(LIBEVENT_SRC)
+	AR=$(AR) RANLIB=$(RANLIB) $(MAKE) -C $(LIBEVENT_SRC) install
+
+test: $(BIN)
+	python3 tests/test_proxy.py $(BIN)
+
+clean:
+	rm -rf $(BIN_DIR)
+
+clean-libevent:
+	-$(MAKE) -C $(LIBEVENT_SRC) distclean
+	rm -rf $(LIBEVENT_PREFIX)
+
+distclean: clean clean-libevent
+	rm -rf $(BUILD_DIR)
+
+.PHONY: all clean clean-libevent distclean test strip
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bbdfa93
--- /dev/null
+++ b/README.md
@@ -0,0 +1,9 @@
+tinyproxy
+
+This is a work in progress project.
+
+## Rules of thumb
+1. Work as a simple inet utils
+1. Be simple and effective
+1. Runs in foreground
+1. Uses libevent2 (for readability and future maintainance)
diff --git a/file_conf.c b/file_conf.c
new file mode 100644
index 0000000..4780bb1
--- /dev/null
+++ b/file_conf.c
@@ -0,0 +1,225 @@
+// file_conf.c
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <stdbool.h>
+#include <ctype.h>
+#include <errno.h>
+
+#include "file_conf.h"
+
+#define MAX_LINE_LEN 512
+
+static char *trim(char *s)
+{
+	while (isspace((unsigned char)*s)) {
+		s++;
+	}
+
+	if (*s == '\0') {
+		return s;
+	}
+
+	char *end = s + strlen(s) - 1;
+	while (end > s && isspace((unsigned char)*end)) {
+		*end = '\0';
+		end--;
+	}
+
+	return s;
+}
+
+static int parse_port(const char *s, uint16_t *out)
+{
+	char *end = NULL;
+	errno = 0;
+
+	unsigned long v = strtoul(s, &end, 10);
+
+	if (errno != 0 || *s == '\0' || *end != '\0' || v == 0 || v > 65535) {
+		return -1;
+	}
+
+	*out = (uint16_t)v;
+	return 0;
+}
+
+static int split_host_port(const char *input,
+                           char *host, size_t host_len,
+                           uint16_t *port)
+{
+    const char *colon = strrchr(input, ':');
+    if (!colon) {
+        return -1;
+    }
+
+    size_t hlen = (size_t)(colon - input);
+    const char *port_s = colon + 1;
+
+    if (hlen >= host_len || *port_s == '\0') {
+        return -1;
+    }
+
+    memcpy(host, input, hlen);
+    host[hlen] = '\0';
+
+    if (parse_port(port_s, port) != 0) {
+        return -1;
+    }
+
+    // Allow ":80" as shorthand for all interfaces.
+    if (host[0] == '\0') {
+        snprintf(host, host_len, "0.0.0.0");
+    }
+
+    return 0;
+}
+
+static int parse_proto(const char *s, enum proto *out)
+{
+	if (strcmp(s, "tcp") == 0) {
+		*out = PROTO_TCP;
+		return 0;
+	}
+
+	if (strcmp(s, "udp") == 0) {
+		*out = PROTO_UDP;
+		return 0;
+	}
+
+	return -1;
+}
+
+static int parse_route_line(char *line, struct route *route)
+{
+	char *fields[4] = {0};
+	size_t count = 0;
+
+	char *tok = strtok(line, " \t");
+	while (tok && count < 4) {
+		fields[count++] = tok;
+		tok = strtok(NULL, " \t");
+	}
+
+	if (tok != NULL) {
+		return -1; // too many fields
+	}
+
+	if (count < 3) {
+		return -1;
+	}
+
+	memset(route, 0, sizeof(*route));
+
+	if (split_host_port(fields[0],
+						route->listen_host, sizeof(route->listen_host),
+						&route->listen_port) != 0) {
+		return -1;
+	}
+
+	if (split_host_port(fields[1],
+						route->upstream_host, sizeof(route->upstream_host),
+						&route->upstream_port) != 0) {
+		return -1;
+	}
+
+	if (parse_proto(fields[2], &route->proto) != 0) {
+		return -1;
+	}
+
+	route->send_proxy_v2 = false;
+
+	if (count == 4) {
+		if (strcmp(fields[3], "proxyv2") != 0) {
+			return -1;
+		}
+
+		route->send_proxy_v2 = true;
+	}
+
+	return 0;
+}
+
+int load_routes_from_file(const char *path, struct route **routes_out, size_t *count_out)
+{
+	FILE *fp = fopen(path, "r");
+	if (!fp) {
+		return -errno;
+	}
+
+	struct route *routes = NULL;
+	size_t count = 0;
+	size_t cap = 0;
+
+	char buf[MAX_LINE_LEN];
+	unsigned int line_no = 0;
+
+	while (fgets(buf, sizeof(buf), fp)) {
+		line_no++;
+
+		// Reject overlong lines.
+		if (!strchr(buf, '\n') && !feof(fp)) {
+			fclose(fp);
+			free(routes);
+			return -E2BIG;
+		}
+
+		char *line = trim(buf);
+
+		if (*line == '\0' || *line == '#') {
+			continue;
+		}
+
+		// Strip inline comments.
+		char *hash = strchr(line, '#');
+		if (hash) {
+			*hash = '\0';
+			line = trim(line);
+			if (*line == '\0') {
+				continue;
+			}
+		}
+
+		if (count == cap) {
+			size_t new_cap = cap ? cap * 2 : 8;
+			struct route *new_routes = realloc(routes, new_cap * sizeof(*routes));
+			if (!new_routes) {
+				fclose(fp);
+				free(routes);
+				return -ENOMEM;
+			}
+
+			routes = new_routes;
+			cap = new_cap;
+		}
+
+		if (parse_route_line(line, &routes[count]) != 0) {
+			fprintf(stderr, "%s:%u: invalid route config\n", path, line_no);
+			fclose(fp);
+			free(routes);
+			return -EINVAL;
+		}
+
+		count++;
+	}
+
+	if (ferror(fp)) {
+		int err = errno;
+		fclose(fp);
+		free(routes);
+		return -err;
+	}
+
+	fclose(fp);
+
+	*routes_out = routes;
+	*count_out = count;
+
+	return 0;
+}
+
+void free_routes(struct route *routes)
+{
+	free(routes);
+}
diff --git a/file_conf.h b/file_conf.h
new file mode 100644
index 0000000..2a76145
--- /dev/null
+++ b/file_conf.h
@@ -0,0 +1,12 @@
+#ifndef FILE_CONF_H
+#define FILE_CONF_H
+
+#include <stddef.h>
+#include <stdbool.h>
+
+#include "route.h"
+
+int load_routes_from_file(const char *path, struct route **routes_out, size_t *count_out);
+void free_routes(struct route *routes);
+
+#endif
diff --git a/route.h b/route.h
new file mode 100644
index 0000000..90ecea0
--- /dev/null
+++ b/route.h
@@ -0,0 +1,34 @@
+#ifndef ROUTE_H
+#define ROUTE_H
+
+#include <event2/bufferevent.h>
+#include <stdbool.h>
+
+enum proto {
+    PROTO_TCP,
+    PROTO_UDP,
+};
+
+struct route {
+    char listen_host[64];
+	uint16_t listen_port;
+
+    char upstream_host[64];
+	uint16_t upstream_port;
+
+    enum proto proto;
+    bool send_proxy_v2;
+};
+
+typedef struct conn_s {
+    struct bufferevent *client;
+    struct bufferevent *upstream;
+} conn_t;
+
+struct listener_ctx {
+    struct event_base *base;
+    const struct route *route;
+    struct evconnlistener *listener;
+};
+
+#endif
diff --git a/tcp_route.c b/tcp_route.c
new file mode 100644
index 0000000..0421b67
--- /dev/null
+++ b/tcp_route.c
@@ -0,0 +1,220 @@
+#include <event2/bufferevent.h>
+#include <event2/buffer.h>
+#include <event2/event.h>
+#include <event2/listener.h>
+#include <event2/util.h>
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "file_conf.h"
+#include "tcp_route.h"
+
+static void free_conn(conn_t *conn) {
+	if (conn == NULL) {
+		return;
+	}
+
+	if (conn->client != NULL) {
+		bufferevent_free(conn->client);
+	}
+
+	if (conn->upstream != NULL) {
+		bufferevent_free(conn->upstream);
+	}
+
+	free(conn);
+}
+
+static void pipe_read_cb(struct bufferevent *src, void *arg) {
+	conn_t *conn = arg;
+
+	struct bufferevent *dst;
+
+	if (src == conn->client) {
+		dst = conn->upstream;
+	} else if (src == conn->upstream) {
+		dst = conn->client;
+	} else {
+		return;
+	}
+
+	struct evbuffer *input = bufferevent_get_input(src);
+	struct evbuffer *output = bufferevent_get_output(dst);
+
+	evbuffer_add_buffer(output, input);
+}
+
+static void event_cb(struct bufferevent *bev, short events, void *arg) {
+	conn_t *conn = arg;
+
+	if (events & BEV_EVENT_CONNECTED) {
+		bufferevent_enable(conn->client, EV_READ | EV_WRITE);
+		bufferevent_enable(conn->upstream, EV_READ | EV_WRITE);
+		return;
+	}
+
+	if (events & (BEV_EVENT_EOF | BEV_EVENT_ERROR | BEV_EVENT_TIMEOUT)) {
+		if (events & BEV_EVENT_ERROR) {
+			int err = EVUTIL_SOCKET_ERROR();
+			fprintf(stderr, "connection error: %s\n",
+					evutil_socket_error_to_string(err));
+		}
+
+		free_conn(conn);
+	}
+
+	(void)bev;
+}
+
+static void accept_cb(
+	struct evconnlistener *listener,
+	evutil_socket_t client_fd,
+	struct sockaddr *addr,
+	int socklen,
+	void *arg
+) {
+	(void)listener;
+	(void)addr;
+	(void)socklen;
+
+	struct listener_ctx *ctx = arg;
+	const struct route *r = ctx->route;
+	struct event_base *base = ctx->base;
+
+	conn_t *conn = calloc(1, sizeof(*conn));
+	if (conn == NULL) {
+		evutil_closesocket(client_fd);
+		return;
+	}
+
+	conn->client = bufferevent_socket_new(base, client_fd, BEV_OPT_CLOSE_ON_FREE);
+	conn->upstream = bufferevent_socket_new(base, -1, BEV_OPT_CLOSE_ON_FREE);
+
+	if (conn->client == NULL || conn->upstream == NULL) {
+		free_conn(conn);
+		return;
+	}
+
+	bufferevent_setcb(
+		conn->client,
+		pipe_read_cb,
+		NULL,
+		event_cb,
+		conn
+	);
+
+	bufferevent_setcb(
+		conn->upstream,
+		pipe_read_cb,
+		NULL,
+		event_cb,
+		conn
+	);
+
+	struct sockaddr_in upstream_addr;
+	memset(&upstream_addr, 0, sizeof(upstream_addr));
+	upstream_addr.sin_family = AF_INET;
+	upstream_addr.sin_port = htons(r->upstream_port);
+
+	if (inet_pton(AF_INET, r->upstream_host, &upstream_addr.sin_addr) != 1) {
+		fprintf(stderr, "invalid upstream address\n");
+		free_conn(conn);
+		return;
+	}
+
+	if (bufferevent_socket_connect(
+			conn->upstream,
+			(struct sockaddr *)&upstream_addr,
+			sizeof(upstream_addr)
+		) < 0) {
+		fprintf(stderr, "upstream connect failed\n");
+		free_conn(conn);
+		return;
+	}
+
+	/*
+	 * Important:
+	 * The upstream connection is async. libevent will emit BEV_EVENT_CONNECTED
+	 * later. This simple proxy enables client reading immediately.
+	 *
+	 * For PROXY v2, we need to write the PROXY header to
+	 * conn->upstream output before forwarding client bytes.
+	 */
+}
+
+static void accept_error_cb(struct evconnlistener *listener, void *arg) {
+	struct event_base *base = arg;
+	int err = EVUTIL_SOCKET_ERROR();
+
+	fprintf(stderr, "accept error: %s\n",
+			evutil_socket_error_to_string(err));
+
+	evconnlistener_free(listener);
+	event_base_loopexit(base, NULL);
+}
+
+int start_tcp_route(
+    struct event_base *base,
+    const struct route *r,
+    struct listener_ctx **out)
+{
+    struct sockaddr_in listen_addr;
+    memset(&listen_addr, 0, sizeof(listen_addr));
+    listen_addr.sin_family = AF_INET;
+    listen_addr.sin_port = htons(r->listen_port);
+
+    if (inet_pton(AF_INET, r->listen_host, &listen_addr.sin_addr) != 1) {
+        fprintf(stderr, "invalid listen address: %s\n", r->listen_host);
+        return -EINVAL;
+    }
+
+    struct listener_ctx *ctx = calloc(1, sizeof(*ctx));
+    if (ctx == NULL) {
+        return -ENOMEM;
+    }
+
+    ctx->base = base;
+    ctx->route = r;
+
+    ctx->listener = evconnlistener_new_bind(
+        base,
+        accept_cb,
+        ctx,
+        LEV_OPT_CLOSE_ON_FREE | LEV_OPT_REUSEABLE,
+        128,
+        (struct sockaddr *)&listen_addr,
+        sizeof(listen_addr)
+    );
+
+    if (ctx->listener == NULL) {
+        fprintf(stderr, "evconnlistener_new_bind failed\n");
+        free(ctx);
+        return -EADDRINUSE;
+    }
+
+    evconnlistener_set_error_cb(ctx->listener, accept_error_cb);
+
+    fprintf(stderr, "listening on %s:%u, forwarding to %s:%u\n",
+            r->listen_host, r->listen_port,
+            r->upstream_host, r->upstream_port);
+
+    *out = ctx;
+    return 0;
+}
+
+void free_tcp_route(struct listener_ctx *ctx)
+{
+    if (ctx == NULL) {
+        return;
+    }
+
+    if (ctx->listener != NULL) {
+        evconnlistener_free(ctx->listener);
+    }
+
+    free(ctx);
+}
diff --git a/tcp_route.h b/tcp_route.h
new file mode 100644
index 0000000..99e2cc5
--- /dev/null
+++ b/tcp_route.h
@@ -0,0 +1,12 @@
+#ifndef TCP_ROUTE_H
+#define TCP_ROUTE_H
+
+#include "route.h"
+
+int start_tcp_route(
+    struct event_base *base,
+    const struct route *r,
+    struct listener_ctx **out);
+
+void free_tcp_route(struct listener_ctx *ctx);
+#endif
diff --git a/tests/test_proxy.py b/tests/test_proxy.py
new file mode 100755
index 0000000..5a9e5cd
--- /dev/null
+++ b/tests/test_proxy.py
@@ -0,0 +1,250 @@
+#!/usr/bin/env python3
+
+import asyncio
+import os
+import resource
+import signal
+import subprocess
+import sys
+import time
+import tempfile
+
+LISTEN_HOST = "127.0.0.1"
+PROXY_PORT = 31232
+BACKEND_PORT = 41232
+
+def raise_fd_limit(wanted: int) -> None:
+	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:
+	raise_fd_limit(65535)
+
+	backend_server = await asyncio.start_server(
+		echo_handler,
+		LISTEN_HOST,
+		BACKEND_PORT,
+		backlog=4096,
+	)
+
+	with tempfile.NamedTemporaryFile("w", suffix=".conf") as f:
+		f.write(f"{LISTEN_HOST}:{PROXY_PORT} {LISTEN_HOST}:{BACKEND_PORT} tcp\n")
+		f.flush()
+
+		proxy = subprocess.Popen(
+			[proxy_bin, "-c", f.name],
+			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}"
+			)
+
+		try:
+			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:
+			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()
+
+			stderr = proxy.stderr.read() if proxy.stderr else ""
+			if stderr:
+				print("\nproxy stderr:")
+				print(stderr)
+
+
+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())
diff --git a/tinyproxy.c b/tinyproxy.c
new file mode 100644
index 0000000..c4b9416
--- /dev/null
+++ b/tinyproxy.c
@@ -0,0 +1,146 @@
+#include <event2/event.h>
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "file_conf.h"
+#include "tcp_route.h"
+
+static void usage(FILE *out, const char *prog)
+{
+	fprintf(out,
+		"usage: %s [-c config-file]\n"
+		"\n"
+		"options:\n"
+		"  -c FILE   path to config file (default: tinyproxy.conf)\n"
+		"  -v		show the version\n"
+		"  -h		show this help\n",
+		prog);
+}
+
+int main(int argc, char **argv)
+{
+	const char *conf_path = "tinyproxy.conf";
+	int opt;
+
+	while ((opt = getopt(argc, argv, "c:h:v")) != -1) {
+		switch (opt) {
+		case 'c':
+			if (optarg == NULL || optarg[0] == '\0') {
+				fprintf(stderr, "missing config path after -c\n");
+				return 2;
+			}
+			conf_path = optarg;
+			break;
+
+		case 'h':
+			usage(stdout, argv[0]);
+			return 0;
+
+		case 'v':
+			fprintf(stdout, "tinyproxy v0.0.1\n");
+			return 0;
+
+		default:
+			usage(stderr, argv[0]);
+			return 2;
+		}
+	}
+
+	if (optind != argc) {
+		fprintf(stderr, "unexpected argument: %s\n", argv[optind]);
+		usage(stderr, argv[0]);
+		return 2;
+	}
+
+	struct route *routes = NULL;
+	size_t route_count = 0;
+
+	int rc = load_routes_from_file(conf_path, &routes, &route_count);
+	if (rc != 0) {
+		fprintf(stderr, "failed to load config %s: %s\n",
+			conf_path, strerror(-rc));
+		return 1;
+	}
+
+	if (route_count == 0) {
+		fprintf(stderr, "config %s has no routes\n", conf_path);
+		free_routes(routes);
+		return 1;
+	}
+
+	struct listener_ctx **tcp_ctxs = calloc(route_count, sizeof(*tcp_ctxs));
+	if (tcp_ctxs == NULL) {
+		free_routes(routes);
+		return 1;
+	}
+
+	size_t tcp_ctx_count = 0;
+
+	struct event_base *base = event_base_new();
+	if (base == NULL) {
+		fprintf(stderr, "event_base_new failed\n");
+		return 1;
+	}
+
+	for (size_t i = 0; i < route_count; i++) {
+		const struct route *r = &routes[i];
+
+		switch (r->proto) {
+		case PROTO_TCP:
+			rc = start_tcp_route(base, r, &tcp_ctxs[tcp_ctx_count]);
+			if (rc == 0) {
+				tcp_ctx_count++;
+			}
+			break;
+
+		case PROTO_UDP:
+			// rc = start_udp_route(r);
+			fprintf(stderr, "skipping udp route: Not implemented yet\n");
+			break;
+
+		default:
+			fprintf(stderr, "route %zu has unknown protocol\n", i);
+			rc = -EINVAL;
+			break;
+		}
+
+		if (rc != 0) {
+			fprintf(stderr,
+					"failed to start route %zu: %s:%u -> %s:%u: %s\n",
+					i,
+					r->listen_host, r->listen_port,
+					r->upstream_host, r->upstream_port,
+					strerror(-rc));
+
+			for (size_t j = 0; j < tcp_ctx_count; j++) {
+				free_tcp_route(tcp_ctxs[j]);
+			}
+
+			free(tcp_ctxs);
+			event_base_free(base);
+			free_routes(routes);
+			return 1;
+		}
+	}
+
+	rc = event_base_dispatch(base);
+
+	for (size_t i = 0; i < tcp_ctx_count; i++) {
+		free_tcp_route(tcp_ctxs[i]);
+	}
+
+	free(tcp_ctxs);
+	event_base_free(base);
+	free_routes(routes);
+
+	if (rc != 0) {
+		fprintf(stderr, "event loop failed: %s\n", strerror(-rc));
+		return 1;
+	}
+
+	return 0;
+}
diff --git a/tinyproxy.conf b/tinyproxy.conf
new file mode 100644
index 0000000..df3f5e0
--- /dev/null
+++ b/tinyproxy.conf
@@ -0,0 +1,5 @@
+# listen      upstream        proto  option
+:80           10.0.1.1:80     tcp
+:443          10.0.1.1:443    tcp    proxyv2
+:12345        10.0.1.1:12345  udp
+:19132        10.0.1.1:19132  udp    proxyv2