penguin/tinyproxy

An L4 proxy designed to act as a tiny transparent shim

commit b76bde1b349f6246d29ce3dd767b97ed6d3f155b

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-05-28T13:49:32Z
subjectRefactor
commit b76bde1b349f6246d29ce3dd767b97ed6d3f155b
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-05-28T13:49:32Z

    Refactor
---
 docs/tinyproxy.1.scd                  |  89 ++++++++++++++--
 src/{udp_route.c => datagram_route.c} |  46 +++-----
 src/datagram_route.h                  |  32 ++++++
 src/endpoint.c                        |  44 ++++++++
 src/endpoint.h                        |  40 +++++++
 src/file_conf.c                       | 145 +++++++++++++++++--------
 src/file_conf.h                       |   1 +
 src/route.c                           |  40 +------
 src/route.h                           |  35 +-----
 src/route_runtime.c                   |  74 +++++++++++++
 src/route_runtime.h                   |  27 +++++
 src/{tcp_route.c => stream_route.c}   |  33 +++---
 src/stream_route.h                    |  21 ++++
 src/tcp_route.h                       |  27 -----
 src/tinyproxy.c                       | 195 ++++++++++++++++++++++------------
 src/udp_route.h                       |  16 ---
 src/worker.h                          |  11 ++
 tests/support.py                      |  12 +--
 tests/test_haproxy_proxy_v2.py        |   7 +-
 tests/test_tcp_backpressure.py        |  14 +--
 tests/test_tcp_connect_timeout.py     |  14 +--
 tests/test_tcp_idle_timeout.py        |  16 +--
 tests/test_tcp_keep_alive.py          |   7 +-
 tests/test_udp_idle_timeout.py        |   9 +-
 tests/test_udp_proxy_v2.py            |   9 +-
 tinyproxy.conf                        |  75 +++++++++----
 26 files changed, 693 insertions(+), 346 deletions(-)

diff --git a/docs/tinyproxy.1.scd b/docs/tinyproxy.1.scd
index 6071c05..5c84b13 100644
--- a/docs/tinyproxy.1.scd
+++ b/docs/tinyproxy.1.scd
@@ -10,24 +10,97 @@ tinyproxy - lightweight TCP/UDP proxy
 
 # DESCRIPTION
 
-tinyproxy forwards TCP and UDP traffic.
+tinyproxy forwards TCP and UDP traffic between endpoints.
 
-Supports:
+Supported endpoint types:
+- TCP
+- UDP
+- UNIX stream sockets
+- UNIX datagram sockets
+- file:// backends
+
+Features:
 - PROXY protocol v2
-- UNIX sockets
-- UDP forwarding
+- idle and connect timeouts
+- keep-alive support
+- multiple routes
+- IPv4 and IPv6
+
+Routes may be configured using a config file or directly from the command line with *-L*.
 
 # OPTIONS
 
 *-c* <config>
-	Load config file.
+	Load configuration file.
+
+*-L* <route>
+	Add a route using the same syntax as one config file line.
+	May be specified multiple times.
 
 *-v*
-	Show version.
+	Show version information.
+
+*-h*
+	Show help.
+
+# CONFIGURATION
+
+Each route uses the following syntax:
+
+	listen <listen-proto> <listen-endpoint> <backend-proto> <backend-endpoint> [options...]
+
+Supported protocols:
+- tcp
+- udp
+- unix
+- unix-dgram
+- file
+
+# CONFIGURATION EXAMPLES
+
+Forward TCP traffic:
+
+	listen tcp 0.0.0.0:80 tcp 10.0.1.1:80
+
+Enable PROXY protocol v2 and keep-alive:
+
+	listen tcp 0.0.0.0:443 tcp 10.0.1.1:443 proxy_v2 keep_alive
+
+Forward UDP traffic:
+
+	listen udp 0.0.0.0:5353 udp 10.0.1.1:53
+
+Forward to UNIX socket:
+
+	listen tcp 127.0.0.1:8080 unix /tmp/backend.sock
+
+Serve static HTTP response from file:
+
+	listen tcp 127.0.0.1:8080 file /tmp/response.http
+
+# COMMAND LINE EXAMPLES
+
+Run with config file:
+
+	tinyproxy -c /etc/tinyproxy.conf
+
+Define routes directly from the command line:
+
+	tinyproxy \
+		-L "tcp 0.0.0.0:80 tcp 10.0.1.1:80" \
+		-L "tcp 0.0.0.0:443 tcp 10.0.1.1:443 proxy_v2"
+
+# EXIT STATUS
+
+*0*
+	Success.
+
+*1*
+	Failure.
 
-# EXAMPLES
+# SEE ALSO
 
-	tinyproxyc -c /etc/tinyproxy.conf
+	https://github.com/tgckpg/tinyproxy
 
 # AUTHORS
 
diff --git a/src/udp_route.c b/src/datagram_route.c
similarity index 95%
rename from src/udp_route.c
rename to src/datagram_route.c
index ed67cb4..d05f6f7 100644
--- a/src/udp_route.c
+++ b/src/datagram_route.c
@@ -14,16 +14,17 @@
 #include "compat_socket.h"
 #include "compat_file.h"
 #include "proxy_proto_v2.h"
+#include "worker.h"
 #include "route.h"
-#include "udp_route.h"
+#include "datagram_route.h"
 #include "x_builtins.h"
 
 #define UDP_MAX_PACKET 65535
 
-struct udp_route_ctx;
+struct datagram_route_ctx;
 
 struct udp_client {
-	struct udp_route_ctx *ctx;
+	struct datagram_route_ctx *ctx;
 
 	evutil_socket_t fd;
 	struct event *ev;
@@ -38,20 +39,6 @@ struct udp_client {
 	struct udp_client *next;
 };
 
-struct udp_route_ctx {
-	struct event_base *base;
-	struct worker *worker;
-	const struct route *route;
-
-	evutil_socket_t listen_fd;
-	struct event *listen_ev;
-
-	struct sockaddr_storage local_addr;
-	socklen_t local_addr_len;
-
-	struct udp_client *clients;
-};
-
 static int sockaddr_equal(
 	const struct sockaddr_storage *a,
 	socklen_t a_len,
@@ -90,7 +77,7 @@ static void free_udp_client(struct udp_client *c)
 	free(c);
 }
 
-static void cleanup_idle_udp_clients(struct udp_route_ctx *ctx)
+static void cleanup_idle_udp_clients(struct datagram_route_ctx *ctx)
 {
 	time_t now = time(NULL);
 	struct udp_client **pp = &ctx->clients;
@@ -116,7 +103,7 @@ static void cleanup_idle_udp_clients(struct udp_route_ctx *ctx)
 }
 
 static struct udp_client *find_udp_client(
-	struct udp_route_ctx *ctx,
+	struct datagram_route_ctx *ctx,
 	const struct sockaddr_storage *addr,
 	socklen_t addr_len
 ) {
@@ -134,7 +121,7 @@ static void upstream_read_cb(evutil_socket_t fd, short events, void *arg)
 	(void)events;
 
 	struct udp_client *c = arg;
-	struct udp_route_ctx *ctx = c->ctx;
+	struct datagram_route_ctx *ctx = c->ctx;
 
 	unsigned char buf[UDP_MAX_PACKET];
 
@@ -394,7 +381,7 @@ static int connect_udp_upstream(struct udp_client *c, const struct endpoint *ups
 }
 
 static struct udp_client *create_udp_client(
-	struct udp_route_ctx *ctx,
+	struct datagram_route_ctx *ctx,
 	const struct sockaddr_storage *client_addr,
 	socklen_t client_addr_len
 ) {
@@ -456,7 +443,7 @@ static int send_udp_payload_to_upstream(
 	const unsigned char *payload,
 	size_t payload_len
 ) {
-	struct udp_route_ctx *ctx = c->ctx;
+	struct datagram_route_ctx *ctx = c->ctx;
 	const struct route *r = ctx->route;
 
 	if (!r->opts.proxy_v2) {
@@ -515,7 +502,7 @@ static void listen_read_cb(evutil_socket_t fd, short events, void *arg)
 {
 	(void)events;
 
-	struct udp_route_ctx *ctx = arg;
+	struct datagram_route_ctx *ctx = arg;
 
 	cleanup_idle_udp_clients(ctx);
 
@@ -594,10 +581,10 @@ static void listen_read_cb(evutil_socket_t fd, short events, void *arg)
 	}
 }
 
-int start_udp_route(
+int start_datagram_route(
 	struct worker *w,
 	const struct route *r,
-	struct udp_route_ctx **out
+	struct datagram_route_ctx *ctx
 ) {
 	if (r->listen.kind != ENDPOINT_INET) {
 		LOG_ERROR("unsupported udp listen endpoint",
@@ -640,7 +627,7 @@ int start_udp_route(
 		return -EINVAL;
 	}
 
-	struct udp_route_ctx *ctx = calloc(1, sizeof(*ctx));
+	memset(ctx, 0, sizeof(*ctx));
 	if (ctx == NULL) {
 		return -ENOMEM;
 	}
@@ -657,7 +644,7 @@ int start_udp_route(
 		LOG_ERROR("udp listen socket failed",
 			"err", _LOGV(evutil_socket_error_to_string(err))
 		);
-		free(ctx);
+		memset(ctx, 0, sizeof(*ctx));
 		return -errno;
 	}
 
@@ -745,11 +732,10 @@ int start_udp_route(
 		"options", _LOGV(opts[0] ? opts : "")
 	);
 
-	*out = ctx;
 	return 0;
 }
 
-void free_udp_route(struct udp_route_ctx *ctx)
+void stop_datagram_route(struct datagram_route_ctx *ctx)
 {
 	if (ctx == NULL) {
 		return;
@@ -770,5 +756,5 @@ void free_udp_route(struct udp_route_ctx *ctx)
 		c = next;
 	}
 
-	free(ctx);
+	memset(ctx, 0, sizeof(*ctx));
 }
diff --git a/src/datagram_route.h b/src/datagram_route.h
new file mode 100644
index 0000000..b35d0d7
--- /dev/null
+++ b/src/datagram_route.h
@@ -0,0 +1,32 @@
+#ifndef DATAGRAM_ROUTE_H
+#define DATAGRAM_ROUTE_H
+
+#include <event2/util.h>
+#include "compat_socket.h"
+
+struct event;
+struct event_base;
+struct worker;
+struct route;
+struct udp_client;
+
+struct datagram_route_ctx {
+	struct event_base *base;
+	struct worker *worker;
+	const struct route *route;
+
+	evutil_socket_t listen_fd;
+	struct event *listen_ev;
+
+	struct sockaddr_storage local_addr;
+	socklen_t local_addr_len;
+
+	struct udp_client *clients;
+};
+
+int start_datagram_route(struct worker *w, const struct route *r,
+	struct datagram_route_ctx *ctx);
+
+void stop_datagram_route(struct datagram_route_ctx *ctx);
+
+#endif
diff --git a/src/endpoint.c b/src/endpoint.c
new file mode 100644
index 0000000..740936d
--- /dev/null
+++ b/src/endpoint.c
@@ -0,0 +1,44 @@
+#include <stdio.h>
+#include <errno.h>
+
+#include "endpoint.h"
+
+int endpoint_to_string(const struct endpoint *ep, char *buf, size_t buf_len)
+{
+	if (buf == NULL || buf_len == 0) {
+		return -EINVAL;
+	}
+
+	if (ep == NULL) {
+		snprintf(buf, buf_len, "<nil>");
+		return 0;
+	}
+
+	switch (ep->kind) {
+	case ENDPOINT_INET:
+		snprintf(buf, buf_len, "%s:%u", ep->host, ep->port);
+		return 0;
+
+	case ENDPOINT_UNIX:
+		snprintf(buf, buf_len, "unix:%s", ep->path);
+		return 0;
+
+	case ENDPOINT_UNIX_DGRAM:
+		snprintf(buf, buf_len, "unix-dgram:%s", ep->path);
+		return 0;
+
+	case ENDPOINT_BUILTIN:
+		snprintf(buf, buf_len, "builtin://%s", x_builtin_name(ep->builtin));
+		return 0;
+
+	case ENDPOINT_FILE:
+		snprintf(buf, buf_len, "file://%s", ep->path);
+		return 0;
+
+	default:
+		snprintf(buf, buf_len, "<unknown>");
+		return 0;
+	}
+}
+
+
diff --git a/src/endpoint.h b/src/endpoint.h
new file mode 100644
index 0000000..8ffe14f
--- /dev/null
+++ b/src/endpoint.h
@@ -0,0 +1,40 @@
+#ifndef ENDPOINT_H
+#define ENDPOINT_H
+
+#include <stdint.h>
+
+#include "x_builtins.h"
+
+enum endpoint_proto {
+	PROTO_TCP,
+	PROTO_UDP,
+	PROTO_UNIX,
+	PROTO_UNIX_DGRAM,
+	PROTO_FILE,
+	PROTO_BUILTIN,
+};
+
+enum endpoint_kind {
+	ENDPOINT_INET,
+	ENDPOINT_UNIX,
+	ENDPOINT_UNIX_DGRAM,
+	ENDPOINT_BUILTIN,
+	ENDPOINT_FILE,
+};
+
+struct endpoint {
+	enum endpoint_kind kind;
+
+	char host[256];
+	uint16_t port;
+
+	enum endpoint_proto proto;
+
+	char path[108]; /* sockaddr_un sun_path limit on Linux */
+
+	enum x_builtin_upstream builtin;
+};
+
+int endpoint_to_string(const struct endpoint *ep, char *buf, size_t buf_len);
+
+#endif
diff --git a/src/file_conf.c b/src/file_conf.c
index 68126ea..776299c 100644
--- a/src/file_conf.c
+++ b/src/file_conf.c
@@ -8,8 +8,10 @@
 
 #include "klog.h"
 #include "file_conf.h"
+#include "endpoint.h"
 
 #define MAX_LINE_LEN 512
+#define MAX_ROUTE_FIELDS 32
 
 static char *trim(char *s)
 {
@@ -76,7 +78,7 @@ static int split_host_port(const char *input,
 	return 0;
 }
 
-static int parse_proto(const char *s, enum proto *out)
+static int parse_proto(const char *s, enum endpoint_proto *out)
 {
 	if (strcmp(s, "tcp") == 0) {
 		*out = PROTO_TCP;
@@ -88,6 +90,26 @@ static int parse_proto(const char *s, enum proto *out)
 		return 0;
 	}
 
+	if (strcmp(s, "unix") == 0) {
+		*out = PROTO_UNIX;
+		return 0;
+	}
+
+	if (strcmp(s, "unix-dgram") == 0) {
+		*out = PROTO_UNIX_DGRAM;
+		return 0;
+	}
+
+	if (strcmp(s, "file") == 0) {
+		*out = PROTO_FILE;
+		return 0;
+	}
+
+	if (strcmp(s, "builtin") == 0) {
+		*out = PROTO_BUILTIN;
+		return 0;
+	}
+
 	return -1;
 }
 
@@ -210,52 +232,68 @@ static const char *file_uri_path(const char *s)
 	return path;
 }
 
-static int parse_endpoint(const char *s, struct endpoint *ep)
+static int parse_endpoint(enum endpoint_proto proto, const char *s, struct endpoint *ep)
 {
 	const char *path;
 
 	memset(ep, 0, sizeof(*ep));
+	ep->proto = proto;
 
-	if (strncmp(s, "unix:", 5) == 0) {
-		path = s + 5;
+	switch (proto) {
+	case PROTO_TCP:
+	case PROTO_UDP:
+		ep->kind = ENDPOINT_INET;
 
-		if (path[0] == '\0') {
+		if (split_host_port(s, ep->host, sizeof(ep->host), &ep->port) != 0) {
 			return -1;
 		}
 
-		if (strlen(path) >= sizeof(ep->path)) {
+		return 0;
+
+	case PROTO_UNIX:
+		path = s;
+
+		if (strncmp(path, "unix:", 5) == 0) {
+			path += 5;
+		}
+
+		if (path[0] == '\0' || strlen(path) >= sizeof(ep->path)) {
 			return -1;
 		}
 
 		ep->kind = ENDPOINT_UNIX;
 		strcpy(ep->path, path);
 		return 0;
-	} else if (strncmp(s, "builtin://", 10) == 0) {
-		const char *name = s + 10;
-		enum x_builtin_upstream builtin;
 
-		if (name[0] == '\0') {
-			return -1;
+	case PROTO_UNIX_DGRAM:
+		path = s;
+
+		if (strncmp(path, "unix-dgram:", 11) == 0) {
+			path += 11;
 		}
 
-		if (x_builtin_parse(name, &builtin) < 0) {
+		if (path[0] == '\0' || strlen(path) >= sizeof(ep->path)) {
 			return -1;
 		}
 
-		ep->kind = ENDPOINT_BUILTIN;
-		ep->builtin = builtin;
-
+		ep->kind = ENDPOINT_UNIX_DGRAM;
+		strcpy(ep->path, path);
 		return 0;
-	} else if (strncmp(s, "file://", 7) == 0) {
-		const char *path = file_uri_path(s);
+
+	case PROTO_FILE:
+		if (strncmp(s, "file://", 7) == 0) {
+			path = file_uri_path(s);
+		} else {
+			path = s;
+		}
 
 		if (path[0] == '\0') {
-			LOG_ERROR("missing file:// path");
+			LOG_ERROR("missing file path");
 			return -1;
 		}
 
 		if (strlen(path) >= sizeof(ep->path)) {
-			LOG_ERROR("file:// path too long",
+			LOG_ERROR("file path too long",
 				"max", _LOGV(sizeof(ep->path) - 1)
 			);
 			return -1;
@@ -263,72 +301,95 @@ static int parse_endpoint(const char *s, struct endpoint *ep)
 
 		ep->kind = ENDPOINT_FILE;
 		strcpy(ep->path, path);
-
 		return 0;
-	} else if (strncmp(s, "unix-dgram:", 11) == 0) {
-		path = s + 11;
 
-		if (path[0] == '\0') {
-			return -1;
+	case PROTO_BUILTIN: {
+		enum x_builtin_upstream builtin;
+		const char *name = s;
+
+		if (strncmp(name, "builtin://", 10) == 0) {
+			name += 10;
 		}
 
-		if (strlen(path) >= sizeof(ep->path)) {
+		if (name[0] == '\0' || x_builtin_parse(name, &builtin) < 0) {
 			return -1;
 		}
 
-		ep->kind = ENDPOINT_UNIX_DGRAM;
-		strcpy(ep->path, path);
+		ep->kind = ENDPOINT_BUILTIN;
+		ep->builtin = builtin;
 		return 0;
 	}
-
-	ep->kind = ENDPOINT_INET;
-
-	if (split_host_port(s, ep->host, sizeof(ep->host), &ep->port) != 0) {
-		return -1;
 	}
 
-	return 0;
+	return -1;
 }
 
-static int parse_route_line(char *line, struct route *route)
+int parse_route_line(char *line, struct route *route)
 {
-	char *fields[4] = {0};
+	char *fields[MAX_ROUTE_FIELDS] = {0};
 	size_t count = 0;
+	size_t pos = 0;
 	char *saveptr = NULL;
 	char *tok;
+	enum endpoint_proto listen_proto;
+	enum endpoint_proto backend_proto;
 
 	tok = strtok_r(line, " \t\r\n", &saveptr);
 	while (tok != NULL) {
-		if (count >= 4) {
-			return -1; // too many fields
+		if (count >= MAX_ROUTE_FIELDS) {
+			return -1;
 		}
 
 		fields[count++] = tok;
 		tok = strtok_r(NULL, " \t\r\n", &saveptr);
 	}
 
-	if (count < 3) {
+	if (count == 0) {
+		return -1;
+	}
+
+	/* Config file form:
+	 *   listen tcp 0.0.0.0:80 tcp 10.0.1.1:80 proxy_v2
+	 *
+	 * Also accept CLI-short form:
+	 *   tcp 0.0.0.0:80 tcp 10.0.1.1:80 proxy_v2
+	 */
+	if (strcmp(fields[0], "listen") == 0) {
+		pos = 1;
+	}
+
+	if (count - pos < 4) {
 		return -1;
 	}
 
 	memset(route, 0, sizeof(*route));
 	route_options_set_defaults(&route->opts);
 
-	if (parse_endpoint(fields[0], &route->listen) != 0) {
+	if (parse_proto(fields[pos], &listen_proto) != 0) {
 		return -1;
 	}
+	pos++;
 
-	if (parse_endpoint(fields[1], &route->upstream) != 0) {
+	if (parse_endpoint(listen_proto, fields[pos], &route->listen) != 0) {
 		return -1;
 	}
+	pos++;
 
-	if (parse_proto(fields[2], &route->proto) != 0) {
+	if (parse_proto(fields[pos], &backend_proto) != 0) {
 		return -1;
 	}
+	pos++;
 
-	if (count == 4 && parse_route_options(fields[3], &route->opts) != 0) {
+	if (parse_endpoint(backend_proto, fields[pos], &route->upstream) != 0) {
 		return -1;
 	}
+	pos++;
+
+	for (; pos < count; pos++) {
+		if (parse_route_options(fields[pos], &route->opts) != 0) {
+			return -1;
+		}
+	}
 
 	return 0;
 }
diff --git a/src/file_conf.h b/src/file_conf.h
index 2a76145..5b7baad 100644
--- a/src/file_conf.h
+++ b/src/file_conf.h
@@ -6,6 +6,7 @@
 
 #include "route.h"
 
+int parse_route_line(char *line, struct route *route);
 int load_routes_from_file(const char *path, struct route **routes_out, size_t *count_out);
 void free_routes(struct route *routes);
 
diff --git a/src/route.c b/src/route.c
index 2efcdc6..aa368b9 100644
--- a/src/route.c
+++ b/src/route.c
@@ -1,44 +1,8 @@
 #include <stdio.h>
 #include <errno.h>
-#include "route.h"
-
-int endpoint_to_string(const struct endpoint *ep, char *buf, size_t buf_len)
-{
-	if (buf == NULL || buf_len == 0) {
-		return -EINVAL;
-	}
-
-	if (ep == NULL) {
-		snprintf(buf, buf_len, "<nil>");
-		return 0;
-	}
-
-	switch (ep->kind) {
-	case ENDPOINT_INET:
-		snprintf(buf, buf_len, "%s:%u", ep->host, ep->port);
-		return 0;
 
-	case ENDPOINT_UNIX:
-		snprintf(buf, buf_len, "unix:%s", ep->path);
-		return 0;
-
-	case ENDPOINT_UNIX_DGRAM:
-		snprintf(buf, buf_len, "unix-dgram:%s", ep->path);
-		return 0;
-
-	case ENDPOINT_BUILTIN:
-		snprintf(buf, buf_len, "builtin://%s", x_builtin_name(ep->builtin));
-		return 0;
-
-	case ENDPOINT_FILE:
-		snprintf(buf, buf_len, "file://%s", ep->path);
-		return 0;
-
-	default:
-		snprintf(buf, buf_len, "<unknown>");
-		return 0;
-	}
-}
+#include "worker.h"
+#include "route.h"
 
 void route_options_str(const struct route_options *opts, char *buf, size_t buflen)
 {
diff --git a/src/route.h b/src/route.h
index 7e5c5e3..a8e7e99 100644
--- a/src/route.h
+++ b/src/route.h
@@ -5,19 +5,12 @@
 #include <stddef.h>
 #include <stdint.h>
 
-#include "x_builtins.h"
-
-struct event_base;
+#include "endpoint.h"
 
 #define ROUTE_HOST_MAX 256
 #define ROUTE_DEFAULT_IDLE_TIMEOUT_SEC 60
 #define ROUTE_DEFAULT_CONNECT_TIMEOUT_SEC 5
 
-enum proto {
-	PROTO_TCP,
-	PROTO_UDP,
-};
-
 struct route_options {
 	bool proxy_v2;
 	bool keep_alive;
@@ -26,41 +19,15 @@ struct route_options {
 	int connect_timeout_sec;
 };
 
-enum endpoint_kind {
-	ENDPOINT_INET,
-	ENDPOINT_UNIX,
-	ENDPOINT_UNIX_DGRAM,
-	ENDPOINT_BUILTIN,
-	ENDPOINT_FILE,
-};
-
-struct endpoint {
-	enum endpoint_kind kind;
-
-	char host[256];
-	uint16_t port;
-
-	char path[108]; /* sockaddr_un sun_path limit on Linux */
-
-	enum x_builtin_upstream builtin;
-};
-
 struct route {
 	struct endpoint listen;
 	struct endpoint upstream;
 
-	enum proto proto;
 	struct route_options opts;
 
 	unsigned int line_no;
 };
 
-struct worker {
-	struct event_base *base;
-	size_t id;
-};
-
-int endpoint_to_string(const struct endpoint *ep, char *buf, size_t buf_len);
 void route_options_str(const struct route_options *opts, char *buf, size_t buflen);
 
 #endif
diff --git a/src/route_runtime.c b/src/route_runtime.c
new file mode 100644
index 0000000..089457c
--- /dev/null
+++ b/src/route_runtime.c
@@ -0,0 +1,74 @@
+#include <string.h>
+
+#include "klog.h"
+#include "route.h"
+#include "route_runtime.h"
+
+int start_route(struct worker *w, const struct route *r, struct route_ctx *ctx)
+{
+	int rc = 0;
+	switch (r->listen.proto) {
+
+	case PROTO_TCP:
+		ctx->kind = ROUTE_CTX_STREAM;
+
+		rc = start_stream_route(w, r, &ctx->u.stream);
+		if (rc != 0) {
+			ctx->kind = ROUTE_CTX_NONE;
+			memset(&ctx->u.stream, 0, sizeof(ctx->u.stream));
+		}
+
+		return rc;
+
+	case PROTO_UDP:
+		ctx->kind = ROUTE_CTX_DATAGRAM;
+
+		rc = start_datagram_route(w, r, &ctx->u.datagram);
+		if (rc != 0) {
+			ctx->kind = ROUTE_CTX_NONE;
+			memset(&ctx->u.stream, 0, sizeof(ctx->u.datagram));
+		}
+
+		return rc;
+
+	/* New approach
+	case ENDPOINT_PROTO_TCP:
+	case ENDPOINT_PROTO_UNIX_STREAM:
+		return start_stream_listener(w, r, ctx);
+
+	case ENDPOINT_PROTO_UDP:
+	case ENDPOINT_PROTO_UNIX_DGRAM:
+		return start_datagram_listener(w, r, ctx);
+	*/
+
+	default:
+		LOG_ERROR("route listen endpoint is not listenable",
+			"line", _LOGV(r->line_no),
+			"listen", _LOGV_ENDPOINT(&r->listen)
+		);
+		return -EINVAL;
+	}
+}
+
+void stop_route(struct route_ctx *ctx)
+{
+	if (ctx == NULL) {
+		return;
+	}
+
+	switch (ctx->kind) {
+	case ROUTE_CTX_STREAM:
+		stop_stream_route(&ctx->u.stream);
+		break;
+
+	case ROUTE_CTX_DATAGRAM:
+		stop_datagram_route(&ctx->u.datagram);
+		break;
+
+	case ROUTE_CTX_NONE:
+	default:
+		break;
+	}
+
+	memset(ctx, 0, sizeof(*ctx));
+}
diff --git a/src/route_runtime.h b/src/route_runtime.h
new file mode 100644
index 0000000..fb8b359
--- /dev/null
+++ b/src/route_runtime.h
@@ -0,0 +1,27 @@
+#ifndef ROUTE_RUNTIME_H
+#define ROUTE_RUNTIME_H
+
+#include "route.h"
+#include "stream_route.h"
+#include "datagram_route.h"
+
+enum route_ctx_kind {
+	ROUTE_CTX_NONE,
+	ROUTE_CTX_STREAM,
+	ROUTE_CTX_DATAGRAM,
+};
+
+struct route_ctx {
+	enum route_ctx_kind kind;
+	const struct route *route;
+
+	union {
+		struct stream_route_ctx stream;
+		struct datagram_route_ctx datagram;
+	} u;
+};
+
+int start_route(struct worker *w, const struct route *r, struct route_ctx *ctx);
+void stop_route(struct route_ctx *ctx);
+
+#endif
diff --git a/src/tcp_route.c b/src/stream_route.c
similarity index 97%
rename from src/tcp_route.c
rename to src/stream_route.c
index ce46022..7ff1c84 100644
--- a/src/tcp_route.c
+++ b/src/stream_route.c
@@ -13,8 +13,9 @@
 #include "file_conf.h"
 #include "compat_socket.h"
 #include "proxy_proto_v2.h"
+#include "worker.h"
 #include "route.h"
-#include "tcp_route.h"
+#include "stream_route.h"
 #include "x_builtins.h"
 
 #define BEV_READ_HIGH_WATER (256 * 1024)
@@ -22,13 +23,6 @@
 
 #define FILE_CHUNK_SIZE 4096
 
-struct tcp_route_ctx {
-	struct event_base *accept_base;
-	struct worker *worker;
-	const struct route *route;
-	struct evconnlistener *listener;
-};
-
 typedef struct conn_s {
 	struct worker *owner;
 	const struct route *route;
@@ -637,7 +631,7 @@ static void accept_cb(
 ) {
 	(void)listener;
 
-	struct tcp_route_ctx *ctx = arg;
+	struct stream_route_ctx *ctx = arg;
 	struct accepted_client ac = {
 		.fd = client_fd,
 		.route = ctx->route,
@@ -657,7 +651,7 @@ static void accept_cb(
 
 static void accept_error_cb(struct evconnlistener *listener, void *arg)
 {
-	struct tcp_route_ctx *ctx = arg;
+	struct stream_route_ctx *ctx = arg;
 	int err = EVUTIL_SOCKET_ERROR();
 
 	LOG_ERROR("accept error", "err", _LOGV(evutil_socket_error_to_string(err)));
@@ -666,12 +660,15 @@ static void accept_error_cb(struct evconnlistener *listener, void *arg)
 	event_base_loopexit(ctx->accept_base, NULL);
 }
 
-int start_tcp_route(
+int start_stream_route(
 	struct worker *w,
 	const struct route *r,
-	struct tcp_route_ctx **out)
+	struct stream_route_ctx *ctx)
 {
 	struct sockaddr_in listen_addr;
+
+	memset(ctx, 0, sizeof(*ctx));
+
 	memset(&listen_addr, 0, sizeof(listen_addr));
 	listen_addr.sin_family = AF_INET;
 	listen_addr.sin_port = htons(r->listen.port);
@@ -683,11 +680,6 @@ int start_tcp_route(
 		return -EINVAL;
 	}
 
-	struct tcp_route_ctx *ctx = calloc(1, sizeof(*ctx));
-	if (ctx == NULL) {
-		return -ENOMEM;
-	}
-
 	ctx->accept_base = w->base;
 	ctx->worker = w;
 	ctx->route = r;
@@ -704,7 +696,7 @@ int start_tcp_route(
 
 	if (ctx->listener == NULL) {
 		LOG_ERROR("evconnlistener_new_bind failed");
-		free(ctx);
+		memset(ctx, 0, sizeof(*ctx));
 		return -EADDRINUSE;
 	}
 
@@ -721,11 +713,10 @@ int start_tcp_route(
 		"options", _LOGV(opts[0] ? opts : "")
 	);
 
-	*out = ctx;
 	return 0;
 }
 
-void free_tcp_route(struct tcp_route_ctx *ctx)
+void stop_stream_route(struct stream_route_ctx *ctx)
 {
 	if (ctx == NULL) {
 		return;
@@ -735,7 +726,7 @@ void free_tcp_route(struct tcp_route_ctx *ctx)
 		evconnlistener_free(ctx->listener);
 	}
 
-	free(ctx);
+	memset(ctx, 0, sizeof(*ctx));
 }
 
 #ifdef FUZZ
diff --git a/src/stream_route.h b/src/stream_route.h
new file mode 100644
index 0000000..7ab549e
--- /dev/null
+++ b/src/stream_route.h
@@ -0,0 +1,21 @@
+#ifndef STREAM_ROUTE_H
+#define STREAM_ROUTE_H
+
+struct worker;
+struct route;
+struct event_base;
+struct evconnlistener;
+
+struct stream_route_ctx {
+	struct event_base *accept_base;
+	struct worker *worker;
+	const struct route *route;
+	struct evconnlistener *listener;
+};
+
+int start_stream_route(struct worker *w, const struct route *r,
+	struct stream_route_ctx *ctx);
+
+void stop_stream_route(struct stream_route_ctx *ctx);
+
+#endif
diff --git a/src/tcp_route.h b/src/tcp_route.h
deleted file mode 100644
index ec9b8b1..0000000
--- a/src/tcp_route.h
+++ /dev/null
@@ -1,27 +0,0 @@
-#ifndef TCP_ROUTE_H
-#define TCP_ROUTE_H
-
-#include "route.h"
-#ifdef FUZZ
-#include "compat_socket.h"
-#endif
-
-struct tcp_route_ctx;
-
-int start_tcp_route(
-	struct worker *w,
-	const struct route *r,
-	struct tcp_route_ctx **out);
-
-void free_tcp_route(struct tcp_route_ctx *ctx);
-#endif
-
-#ifdef FUZZ
-int tcp_route_adopt_client_for_fuzz(
-	struct event_base *base,
-	const struct route *r,
-	evutil_socket_t client_fd,
-	const struct sockaddr_storage *peer_addr,
-	socklen_t peer_addr_len
-);
-#endif
diff --git a/src/tinyproxy.c b/src/tinyproxy.c
index 3f2e341..eb146e9 100644
--- a/src/tinyproxy.c
+++ b/src/tinyproxy.c
@@ -10,18 +10,21 @@
 #include "klog.h"
 #include "signals.h"
 #include "file_conf.h"
-#include "tcp_route.h"
-#include "udp_route.h"
+#include "worker.h"
+#include "route.h"
+#include "route_runtime.h"
 
 static void usage(FILE *out, const char *prog)
 {
 	fprintf(out,
-		"usage: %s [-c config-file]\n"
+		"usage: %s [-c config-file | -L route]\n"
 		"\n"
 		"options:\n"
 		"  -c FILE   path to config file (default: tinyproxy.conf)\n"
-		"  -v		show the version\n"
-		"  -h		show this help\n",
+		"  -L ROUTE  add inline route, same syntax as config line\n"
+		"            may be specified multiple times\n"
+		"  -v        show the version\n"
+		"  -h        show this help\n",
 		prog);
 }
 
@@ -48,6 +51,12 @@ int main(int argc, char **argv)
 	int exit_code = 1;
 	int rc;
 
+	int conf_path_set = 0;
+
+	const char **inline_routes = NULL;
+	size_t inline_route_count = 0;
+	size_t inline_route_cap = 0;
+
 #ifdef _WIN32
 	int wsa_started = 0;
 #endif
@@ -55,51 +64,120 @@ int main(int argc, char **argv)
 	struct route *routes = NULL;
 	size_t route_count = 0;
 
-	struct tcp_route_ctx **tcp_ctxs = NULL;
-	struct udp_route_ctx **udp_ctxs = NULL;
-	size_t tcp_ctx_count = 0;
-	size_t udp_ctx_count = 0;
+	struct route_ctx *route_ctxs = NULL;
+	size_t route_ctx_count = 0;
 
 	struct event_base *base = NULL;
 	struct signal_events signals;
 	int signals_started = 0;
 
-	while ((opt = getopt(argc, argv, "c:h:v")) != -1) {
+	while ((opt = getopt(argc, argv, "c:L:h:v")) != -1) {
 		switch (opt) {
-		case 'c':
-			if (optarg == NULL || optarg[0] == '\0') {
-				LOG_ERROR("missing config path after -c");
+			case 'c':
+				if (optarg == NULL || optarg[0] == '\0') {
+					LOG_ERROR("missing config path after -c");
+					free(inline_routes);
+					return 2;
+				}
+				conf_path = optarg;
+				conf_path_set = 1;
+				break;
+
+			case 'L':
+				if (optarg == NULL || optarg[0] == '\0') {
+					LOG_ERROR("missing route after -L");
+					free(inline_routes);
+					return 2;
+				}
+
+				if (inline_route_count == inline_route_cap) {
+					size_t new_cap = inline_route_cap == 0 ? 4 : inline_route_cap * 2;
+					const char **new_routes = realloc(inline_routes,
+							new_cap * sizeof(*inline_routes));
+
+					if (new_routes == NULL) {
+						LOG_ERROR("inline route realloc failed",
+								"err", _LOGV(strerror(errno)));
+						free(inline_routes);
+						return 1;
+					}
+
+					inline_routes = new_routes;
+					inline_route_cap = new_cap;
+				}
+
+				inline_routes[inline_route_count++] = optarg;
+				break;
+
+			case 'h':
+				usage(stdout, argv[0]);
+				free(inline_routes);
+				return 0;
+
+			case 'v':
+				fprintf(stdout, "tinyproxy v0.0.1\n");
+				free(inline_routes);
+				return 0;
+
+			default:
+				usage(stderr, argv[0]);
+				free(inline_routes);
 				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 (conf_path_set && inline_route_count > 0) {
+		LOG_ERROR("-c and -L cannot be used together");
+		usage(stderr, argv[0]);
+		free(inline_routes);
+		return 2;
+	}
+
 	if (optind != argc) {
 		LOG_ERROR("unexpected argument", "argv", _LOGV(argv[optind]));
 		usage(stderr, argv[0]);
 		return 2;
 	}
 
-	rc = load_routes_from_file(conf_path, &routes, &route_count);
-	if (rc != 0) {
-		LOG_ERROR("failed to load config",
-			"path", _LOGV(conf_path),
-			"err", _LOGV(strerror(-rc)));
-		goto out;
+	if (inline_route_count > 0) {
+		routes = calloc(inline_route_count, sizeof(*routes));
+		if (routes == NULL) {
+			LOG_ERROR("route calloc failed", "err", _LOGV(strerror(errno)));
+			goto out;
+		}
+
+		for (size_t i = 0; i < inline_route_count; i++) {
+			char *line = strdup(inline_routes[i]);
+			if (line == NULL) {
+				LOG_ERROR("inline route strdup failed", "err", _LOGV(strerror(errno)));
+				rc = -ENOMEM;
+				goto out;
+			}
+
+			struct route *r = &routes[route_count];
+			r->line_no = (unsigned int)i + 1;
+
+			rc = parse_route_line(line, r);
+			free(line);
+
+			if (rc != 0) {
+				LOG_ERROR("failed to load inline route",
+						"line", _LOGV(r->line_no),
+						"route", _LOGV(inline_routes[i]),
+						"err", _LOGV(strerror(-rc)));
+				goto out;
+			}
+
+			route_count++;
+		}
+	} else {
+		rc = load_routes_from_file(conf_path, &routes, &route_count);
+		if (rc != 0) {
+			LOG_ERROR("failed to load config",
+					"path", _LOGV(conf_path),
+					"err", _LOGV(strerror(-rc)));
+			goto out;
+		}
 	}
 
 	if (route_count == 0) {
@@ -107,15 +185,9 @@ int main(int argc, char **argv)
 		goto out;
 	}
 
-	tcp_ctxs = calloc(route_count, sizeof(*tcp_ctxs));
-	if (tcp_ctxs == NULL) {
-		LOG_ERROR("tcp calloc failed", "err", _LOGV(strerror(errno)));
-		goto out;
-	}
-
-	udp_ctxs = calloc(route_count, sizeof(*udp_ctxs));
-	if (udp_ctxs == NULL) {
-		LOG_ERROR("udp calloc failed", "err", _LOGV(strerror(errno)));
+	route_ctxs = calloc(route_count, sizeof(*route_ctxs));
+	if (route_ctxs == NULL) {
+		LOG_ERROR("route calloc failed", "err", _LOGV(strerror(errno)));
 		goto out;
 	}
 
@@ -149,33 +221,17 @@ int main(int argc, char **argv)
 	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(&w, r, &tcp_ctxs[tcp_ctx_count]);
-			if (rc == 0) {
-				tcp_ctx_count++;
-			}
-			break;
-
-		case PROTO_UDP:
-			rc = start_udp_route(&w, r, &udp_ctxs[udp_ctx_count]);
-			if (rc == 0) {
-				udp_ctx_count++;
-			}
-			break;
-
-		default:
-			LOG_ERROR("route has unknown protocol", "line", _LOGV(r->line_no));
-			rc = -EINVAL;
-			break;
+		rc = start_route(&w, r, &route_ctxs[route_ctx_count]);
+		if (rc == 0) {
+			route_ctx_count++;
 		}
 
 		if (rc != 0) {
 			LOG_ERROR("failed to start route",
-				"line", _LOGV(r->line_no),
-				"listen", _LOGV_ENDPOINT(&r->listen),
-				"upstream", _LOGV_ENDPOINT(&r->upstream)
-			);
+					"line", _LOGV(r->line_no),
+					"listen", _LOGV_ENDPOINT(&r->listen),
+					"upstream", _LOGV_ENDPOINT(&r->upstream)
+					);
 			goto out;
 		}
 	}
@@ -189,21 +245,22 @@ int main(int argc, char **argv)
 	exit_code = 0;
 
 out:
-	for (size_t i = 0; i < tcp_ctx_count; i++) {
-		free_tcp_route(tcp_ctxs[i]);
+	for (size_t i = 0; i < route_ctx_count; i++) {
+		stop_route(&route_ctxs[i]);
 	}
 
 	if (signals_started) {
 		free_signal_handlers(&signals);
 	}
 
-	free(tcp_ctxs);
+	free(route_ctxs);
 
 	if (base != NULL) {
 		event_base_free(base);
 	}
 
 	free_routes(routes);
+	free(inline_routes);
 
 #ifdef _WIN32
 	if (wsa_started) {
diff --git a/src/udp_route.h b/src/udp_route.h
deleted file mode 100644
index 2e11f67..0000000
--- a/src/udp_route.h
+++ /dev/null
@@ -1,16 +0,0 @@
-#ifndef UDP_ROUTE_H
-#define UDP_ROUTE_H
-
-#include "route.h"
-
-struct udp_route_ctx;
-
-int start_udp_route(
-	struct worker *w,
-	const struct route *r,
-	struct udp_route_ctx **out
-);
-
-void free_udp_route(struct udp_route_ctx *ctx);
-
-#endif
diff --git a/src/worker.h b/src/worker.h
new file mode 100644
index 0000000..53c28e6
--- /dev/null
+++ b/src/worker.h
@@ -0,0 +1,11 @@
+#ifndef WORKER_H
+#define WORKER_H
+
+struct event_base;
+
+struct worker {
+	struct event_base *base;
+	size_t id;
+};
+
+#endif
diff --git a/tests/support.py b/tests/support.py
index 8a1bd37..c617f7d 100644
--- a/tests/support.py
+++ b/tests/support.py
@@ -389,9 +389,9 @@ async def run_tinyproxy_with_conf(
 @asynccontextmanager
 async def run_default_tcp_tinyproxy(proxy_bin: str):
 	conf_text = (
-		f"{LISTEN_HOST}:{PROXY_PORT} "
-		f"{LISTEN_HOST}:{BACKEND_PORT} "
-		f"tcp\n"
+		f"listen"
+		f" tcp {LISTEN_HOST}:{PROXY_PORT}"
+		f" tcp {LISTEN_HOST}:{BACKEND_PORT}\n"
 	)
 
 	async with run_echo_backend(LISTEN_HOST, BACKEND_PORT):
@@ -407,9 +407,9 @@ async def run_default_tcp_tinyproxy(proxy_bin: str):
 @asynccontextmanager
 async def run_default_udp_tinyproxy(proxy_bin: str):
 	conf_text = (
-		f"{LISTEN_HOST}:{PROXY_PORT} "
-		f"{LISTEN_HOST}:{BACKEND_PORT} "
-		f"udp\n"
+		f"listen"
+		f" udp {LISTEN_HOST}:{PROXY_PORT}"
+		f" udp {LISTEN_HOST}:{BACKEND_PORT}\n"
 	)
 
 	async with run_udp_echo_backend(LISTEN_HOST, BACKEND_PORT):
diff --git a/tests/test_haproxy_proxy_v2.py b/tests/test_haproxy_proxy_v2.py
index fab0dfa..110616b 100644
--- a/tests/test_haproxy_proxy_v2.py
+++ b/tests/test_haproxy_proxy_v2.py
@@ -65,9 +65,10 @@ backend echo_backend
 	proxy_v2_option = os.environ.get("TINYPROXY_PROXY_V2_OPTION", "proxy_v2")
 
 	tinyproxy_conf = (
-		f"{LISTEN_HOST}:{PROXY_PORT} "
-		f"{LISTEN_HOST}:{HAPROXY_FRONTEND_PORT} "
-		f"tcp {proxy_v2_option}\n"
+		f"listen"
+		f" tcp {LISTEN_HOST}:{PROXY_PORT}"
+		f" tcp {LISTEN_HOST}:{HAPROXY_FRONTEND_PORT}"
+		f" {proxy_v2_option}\n"
 	)
 
 	try:
diff --git a/tests/test_tcp_backpressure.py b/tests/test_tcp_backpressure.py
index 5089172..454eb9f 100644
--- a/tests/test_tcp_backpressure.py
+++ b/tests/test_tcp_backpressure.py
@@ -75,9 +75,10 @@ async def test_tcp_backpressure_when_upstream_does_not_read() -> None:
 		raise SkipTest("TINYPROXY_BIN is not set")
 
 	conf_text = (
-		f"{LISTEN_HOST}:{PROXY_PORT} "
-		f"{LISTEN_HOST}:{BACKEND_PORT} "
-		f"tcp idle_timeout=30\n"
+		f"listen"
+		f" tcp {LISTEN_HOST}:{PROXY_PORT}"
+		f" tcp {LISTEN_HOST}:{BACKEND_PORT}"
+		f" idle_timeout=30\n"
 	)
 
 	stop_backend = asyncio.Event()
@@ -138,9 +139,10 @@ async def test_tcp_backpressure_when_client_does_not_read() -> None:
 		raise SkipTest("TINYPROXY_BIN is not set")
 
 	conf_text = (
-		f"{LISTEN_HOST}:{PROXY_PORT} "
-		f"{LISTEN_HOST}:{BACKEND_PORT} "
-		f"tcp idle_timeout=30\n"
+		f"listen"
+		f" tcp {LISTEN_HOST}:{PROXY_PORT}"
+		f" tcp {LISTEN_HOST}:{BACKEND_PORT}"
+		f" idle_timeout=30\n"
 	)
 
 	backend_result: asyncio.Future[tuple[str, int]] = (
diff --git a/tests/test_tcp_connect_timeout.py b/tests/test_tcp_connect_timeout.py
index c999256..7f28398 100644
--- a/tests/test_tcp_connect_timeout.py
+++ b/tests/test_tcp_connect_timeout.py
@@ -36,9 +36,10 @@ async def test_tcp_connect_timeout_closes_client_connection() -> None:
 		raise SkipTest("TINYPROXY_BIN is not set")
 
 	conf_text = (
-		f"{LISTEN_HOST}:{CONNECT_TIMEOUT_PROXY_PORT} "
-		f"{BLACKHOLE_HOST}:{BLACKHOLE_PORT} "
-		f"tcp connect_timeout=1,idle_timeout=30\n"
+		f"listen"
+		f" tcp {LISTEN_HOST}:{CONNECT_TIMEOUT_PROXY_PORT}"
+		f" tcp {BLACKHOLE_HOST}:{BLACKHOLE_PORT}"
+		f" connect_timeout=1,idle_timeout=30\n"
 	)
 
 	async with run_tinyproxy_with_conf(
@@ -95,9 +96,10 @@ async def test_tcp_connect_timeout_does_not_replace_idle_timeout_after_connect()
 	from .support import BACKEND_PORT
 
 	conf_text = (
-		f"{LISTEN_HOST}:{CONNECT_TIMEOUT_PROXY_PORT} "
-		f"{LISTEN_HOST}:{BACKEND_PORT} "
-		f"tcp connect_timeout=1,idle_timeout=2\n"
+		f"listen"
+		f" tcp {LISTEN_HOST}:{CONNECT_TIMEOUT_PROXY_PORT}"
+		f" tcp {LISTEN_HOST}:{BACKEND_PORT}"
+		f" connect_timeout=1,idle_timeout=2\n"
 	)
 
 	backend_server = await asyncio.start_server(
diff --git a/tests/test_tcp_idle_timeout.py b/tests/test_tcp_idle_timeout.py
index fe3080f..c3dab6d 100644
--- a/tests/test_tcp_idle_timeout.py
+++ b/tests/test_tcp_idle_timeout.py
@@ -45,9 +45,10 @@ async def test_tcp_idle_timeout_closes_idle_connection() -> None:
 		raise SkipTest("TINYPROXY_BIN is not set")
 
 	conf_text = (
-		f"{LISTEN_HOST}:{PROXY_PORT} "
-		f"{LISTEN_HOST}:{BACKEND_PORT} "
-		f"tcp idle_timeout=1\n"
+		f"listen"
+		f" tcp {LISTEN_HOST}:{PROXY_PORT}"
+		f" tcp {LISTEN_HOST}:{BACKEND_PORT}"
+		f" idle_timeout=1\n"
 	)
 
 	backend_server = await asyncio.start_server(
@@ -100,9 +101,10 @@ async def test_tcp_idle_timeout_keeps_active_connection_open() -> None:
 		raise SkipTest("TINYPROXY_BIN is not set")
 
 	conf_text = (
-		f"{LISTEN_HOST}:{PROXY_PORT} "
-		f"{LISTEN_HOST}:{BACKEND_PORT} "
-		f"tcp idle_timeout=2\n"
+		f"listen"
+		f" tcp {LISTEN_HOST}:{PROXY_PORT}"
+		f" tcp {LISTEN_HOST}:{BACKEND_PORT}"
+		f" idle_timeout=2\n"
 	)
 
 	backend_server = await asyncio.start_server(
@@ -149,4 +151,4 @@ async def test_tcp_idle_timeout_keeps_active_connection_open() -> None:
 TESTS = [
 	("test_tcp_idle_timeout_closes_idle_connection", test_tcp_idle_timeout_closes_idle_connection),
 	("test_tcp_idle_timeout_keeps_active_connection_open", test_tcp_idle_timeout_keeps_active_connection_open),
-]
\ No newline at end of file
+]
diff --git a/tests/test_tcp_keep_alive.py b/tests/test_tcp_keep_alive.py
index 4d2c5ac..ddffc7f 100644
--- a/tests/test_tcp_keep_alive.py
+++ b/tests/test_tcp_keep_alive.py
@@ -61,9 +61,10 @@ async def test_tcp_keep_alive_roundtrip() -> None:
 		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"
+		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(
diff --git a/tests/test_udp_idle_timeout.py b/tests/test_udp_idle_timeout.py
index 66d78fd..12d23b4 100644
--- a/tests/test_udp_idle_timeout.py
+++ b/tests/test_udp_idle_timeout.py
@@ -47,9 +47,10 @@ async def test_udp_idle_timeout_expires_client_but_route_still_works() -> None:
 		raise SkipTest("TINYPROXY_BIN is not set")
 
 	conf_text = (
-		f"{LISTEN_HOST}:{PROXY_PORT} "
-		f"{LISTEN_HOST}:{BACKEND_PORT} "
-		f"udp idle_timeout=1\n"
+		f"listen"
+		f" udp {LISTEN_HOST}:{PROXY_PORT} "
+		f" udp {LISTEN_HOST}:{BACKEND_PORT} "
+		f" idle_timeout=1\n"
 	)
 
 	loop = asyncio.get_running_loop()
@@ -114,4 +115,4 @@ TESTS = [
 		"test_udp_idle_timeout_expires_client_but_route_still_works",
 		test_udp_idle_timeout_expires_client_but_route_still_works,
 	),
-]
\ No newline at end of file
+]
diff --git a/tests/test_udp_proxy_v2.py b/tests/test_udp_proxy_v2.py
index fd81ac2..2426189 100644
--- a/tests/test_udp_proxy_v2.py
+++ b/tests/test_udp_proxy_v2.py
@@ -19,9 +19,10 @@ async def test_tinyproxy_sends_proxy_v2_for_udp() -> None:
 		raise SkipTest("TINYPROXY_BIN is not set")
 
 	conf_text = (
-		f"{LISTEN_HOST}:{PROXY_PORT} "
-		f"{LISTEN_HOST}:{UDP_PROXY_V2_BACKEND_PORT} "
-		f"udp proxy_v2\n"
+		f"listen"
+		f" udp {LISTEN_HOST}:{PROXY_PORT} "
+		f" udp {LISTEN_HOST}:{UDP_PROXY_V2_BACKEND_PORT} "
+		f" proxy_v2\n"
 	)
 
 	async with run_udp_proxy_v2_echo_backend(
@@ -79,4 +80,4 @@ async def test_tinyproxy_sends_proxy_v2_for_udp() -> None:
 
 TESTS = [
 	("test_tinyproxy_sends_proxy_v2_for_udp", test_tinyproxy_sends_proxy_v2_for_udp),
-]
\ No newline at end of file
+]
diff --git a/tinyproxy.conf b/tinyproxy.conf
index 3112617..f944557 100644
--- a/tinyproxy.conf
+++ b/tinyproxy.conf
@@ -1,35 +1,66 @@
-# builtin://client_addr
+# Format:
+# listen <listen-proto> <listen-endpoint> <backend-proto> <backend-endpoint> [options...]
+#
+# Endpoint examples:
+#   tcp :80
+#   tcp 0.0.0.0:80
+#   tcp 10.0.1.1:80
+#   udp :19132
+#   unix /tmp/test.sock
+#   unix-dgram /tmp/testd.sock
+#   builtin client_addr
+#   file examples/http-response.txt
+#
+# Options:
+#   proxy_v2
+#   keep_alive
+#   idle_timeout=<seconds>
+#   connect_timeout=<seconds>
+#
+# builtin client_addr
 #   Returns the observed client address as plain text, then closes.
 #   Example response: 127.0.0.1:54321
 #
-# builtin://discard
+# builtin discard
 #   TCP: reads and discards client data until the client closes or timeout expires.
 #   UDP: silently drops each datagram.
 #
-# builtin://hang
+# builtin hang
 #   Accepts the connection/datagram and intentionally produces no response.
 #   TCP: the connection remains open until the client closes or the route idle timeout expires.
 #   UDP: this is effectively a silent drop.
 #
-# builtin://close
+# builtin close
 #   Accepts the connection and immediately closes it without reading or writing.
 #   For UDP, this is equivalent to discard/drop because UDP has no connection to close.
 
-# listen  upstream                   proto  option
-:80       10.0.1.1:80                tcp
-:443      10.0.1.1:443               tcp    proxy_v2,keep_alive
-:12990    unix:/tmp/test.sock        tcp
-:12345    10.0.1.1:12345             udp    idle_timeout=10,connect_timeout=17
-:19132    10.0.1.1:19132             udp    proxy_v2
-:13000    unix-dgram:/tmp/testd.sock udp
-:12995    builtin://client_addr      tcp
-:12996    builtin://hang             tcp    idle_timeout=3,connect_timeout=1
-:12997    builtin://discard          tcp
-:12998    builtin://close            tcp
-:12995    builtin://client_addr      udp
-:12996    builtin://hang             udp
-:12997    builtin://discard          udp
-:12998    builtin://close            udp
-
-:14000    file://examples/http-response.txt tcp
-:14001    file:///does/not/exist            tcp
+
+# TCP stream forwarding.
+listen tcp :80     tcp 10.0.1.1:80
+listen tcp :443    tcp 10.0.1.1:443    proxy_v2,keep_alive
+
+# TCP listener to UNIX stream backend.
+listen tcp :12990  unix /tmp/test.sock
+
+# UDP datagram forwarding.
+listen udp :12345  udp 10.0.1.1:12345  idle_timeout=10,connect_timeout=17
+listen udp :19132  udp 10.0.1.1:19132
+
+# UDP listener to UNIX datagram backend.
+listen udp :13000  unix-dgram /tmp/testd.sock
+
+# Builtin TCP test backends.
+listen tcp :12995  builtin client_addr
+listen tcp :12996  builtin hang         idle_timeout=3,connect_timeout=1
+listen tcp :12997  builtin discard
+listen tcp :12998  builtin close
+
+# Builtin UDP test backends.
+listen udp :12995  builtin client_addr
+listen udp :12996  builtin hang
+listen udp :12997  builtin discard
+listen udp :12998  builtin close
+
+# Static file TCP backends.
+listen tcp :14000  file examples/http-response.txt
+listen tcp :14001  file /does/not/exist