penguin/tinyproxy

An L4 proxy designed to act as a tiny transparent shim

commit 8f5e169ae99f4df067449e0f1cf24012417140c0

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-05-28T02:19:52Z
subjectAdded file:// backend and man pages
commit 8f5e169ae99f4df067449e0f1cf24012417140c0
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-05-28T02:19:52Z

    Added file:// backend and man pages
---
 .gitignore                 |   6 +++
 Makefile                   |   1 +
 docs/tinyproxy.1.scd       |  34 +++++++++++++
 docs/tinyproxy.conf.5.scd  |  91 +++++++++++++++++++++++++++++++++
 examples/http-response.txt |   6 +++
 mk/man.mk                  |  29 +++++++++++
 src/file_conf.c            |  36 +++++++++++++
 src/route.c                |   4 ++
 src/route.h                |   1 +
 src/tcp_route.c            | 124 +++++++++++++++++++++++++++++++++++++++++++++
 src/tinyproxy.c            |  16 ++++++
 tinyproxy.conf             |   3 ++
 12 files changed, 351 insertions(+)

diff --git a/.gitignore b/.gitignore
index 18168ab..654fdc0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 bin/
+dist/
 build/
 corpus/
 libevent2/
@@ -7,6 +8,11 @@ __pycache__
 *.swp
 .DS_Store
 
+# Generated man pages from scdoc
+docs/*.1
+docs/*.5
+docs/*.8
+
 # Prerequisites
 *.d
 
diff --git a/Makefile b/Makefile
index ca1e372..ef9f582 100644
--- a/Makefile
+++ b/Makefile
@@ -4,6 +4,7 @@ PROJECT_ROOT := $(CURDIR)
 
 include mk/common.mk
 include mk/libevent.mk
+include mk/man.mk
 
 BIN := $(BIN_DIR)/tinyproxy$(EXEEXT)
 
diff --git a/docs/tinyproxy.1.scd b/docs/tinyproxy.1.scd
new file mode 100644
index 0000000..6071c05
--- /dev/null
+++ b/docs/tinyproxy.1.scd
@@ -0,0 +1,34 @@
+tinyproxy(1)
+
+# NAME
+
+tinyproxy - lightweight TCP/UDP proxy
+
+# SYNOPSIS
+
+*tinyproxy* [options]
+
+# DESCRIPTION
+
+tinyproxy forwards TCP and UDP traffic.
+
+Supports:
+- PROXY protocol v2
+- UNIX sockets
+- UDP forwarding
+
+# OPTIONS
+
+*-c* <config>
+	Load config file.
+
+*-v*
+	Show version.
+
+# EXAMPLES
+
+	tinyproxyc -c /etc/tinyproxy.conf
+
+# AUTHORS
+
+Penguin ts
diff --git a/docs/tinyproxy.conf.5.scd b/docs/tinyproxy.conf.5.scd
new file mode 100644
index 0000000..93bef01
--- /dev/null
+++ b/docs/tinyproxy.conf.5.scd
@@ -0,0 +1,91 @@
+tinyproxy.conf(5)
+
+# NAME
+
+tinyproxy.conf - tinyproxy configuration file
+
+# DESCRIPTION
+
+tinyproxy.conf defines TCP and UDP forwarding routes.
+
+# GLOBAL OPTIONS
+
+TINYPROXY_RUNTIME_DIR="/tmp/tinyproxy"
+
+# ROUTES
+
+listen upstream protocol [options...]
+
+# OPTIONS
+
+proxy_v2
+	Enable proxy v2 for upstream
+	Default: false
+
+keep_alive
+	Enable keep alive for tcp
+	Default: false
+
+idle_timeout=SECONDS
+	Idle timeout for both listen and upstream
+	Default: 60
+
+connect_timeout=SECONDS
+	Connection timeout for both listen and upstream
+	Default: 5
+
+# ENDPOINTS
+
+unix:
+	unix:/tmp/backend.sock
+
+unix datagram:
+	unix-dgram:/tmp/socket.sock
+
+builtin://client_addr
+	Returns the observed client address as plain text, then closes.
+	Example response: 127.0.0.1:54321
+
+builtin://discard
+	TCP: reads and discards client data until the client closes or timeout expires.
+	UDP: silently drops each datagram.
+
+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
+	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.
+
+file:///path/to/file
+	Return the contents of the file in raw bytes.
+
+# EXAMPLES
+
+Forward HTTPS:
+
+	Direct tcp forward with default settings
+	0.0.0.0:443 10.0.1.10:443 tcp
+
+	Upstream accepts proxy protocol v2
+	0.0.0.0:443 10.0.1.10:443 tcp proxy_v2
+
+UDP route:
+
+	0.0.0.0:53 1.1.1.1:53 udp
+
+Serve a fixed HTTP response:
+
+	$ printf 'HTTP/1.1 200 OK\\r\\nContent-Type: text/plain\\r\\nContent-Length: 14\\r\\nConnection: close\\r\\n\\r\\nhello, world!\\n' > response.http
+
+	0.0.0.0:8080 file://response.http tcp
+
+# FILES
+
+/etc/tinyproxy.conf
+
+# SEE ALSO
+
+tinyproxy(1)
diff --git a/examples/http-response.txt b/examples/http-response.txt
new file mode 100644
index 0000000..d5da047
--- /dev/null
+++ b/examples/http-response.txt
@@ -0,0 +1,6 @@
+HTTP/1.1 200 OK
+Content-Type: text/plain
+Content-Length: 14
+Connection: close
+
+hello, world!
diff --git a/mk/man.mk b/mk/man.mk
new file mode 100644
index 0000000..1d626fd
--- /dev/null
+++ b/mk/man.mk
@@ -0,0 +1,29 @@
+SCDOC ?= scdoc
+
+MAN_SCD := docs/tinyproxy.1.scd \
+           docs/tinyproxy.conf.5.scd
+
+MAN := $(MAN_SCD:.scd=)
+MAN_DIST := $(PROJECT_ROOT)/dist
+
+.PHONY: man clean-man install-man uninstall-man
+
+man: $(MAN)
+
+docs/%: docs/%.scd
+	$(SCDOC) < $< > $@
+
+install-man: man
+	mkdir -p $(MAN_DIST)/usr/share/man/man1
+	mkdir -p $(MAN_DIST)/usr/share/man/man5
+	install -Dm644 docs/tinyproxy.1 \
+		$(MAN_DIST)/usr/share/man/man1/tinyproxy.1
+	install -Dm644 docs/tinyproxy.conf.5 \
+		$(MAN_DIST)/usr/share/man/man5/tinyproxy.conf.5
+
+uninstall-man:
+	rm -f $(DESTDIR)$(PREFIX)/share/man/man1/tinyproxy.1
+	rm -f $(DESTDIR)$(PREFIX)/share/man/man5/tinyproxy.conf.5
+
+clean-man:
+	rm -f $(MAN)
diff --git a/src/file_conf.c b/src/file_conf.c
index 4ca1c21..68126ea 100644
--- a/src/file_conf.c
+++ b/src/file_conf.c
@@ -193,6 +193,23 @@ static inline void route_options_set_defaults(struct route_options *opts)
 	opts->connect_timeout_sec = ROUTE_DEFAULT_CONNECT_TIMEOUT_SEC;
 }
 
+static const char *file_uri_path(const char *s)
+{
+	const char *path = s + 7; /* file:// */
+
+#ifdef _WIN32
+	/* Accept file:///C:/path as C:/path */
+	if (path[0] == '/' &&
+	    ((path[1] >= 'A' && path[1] <= 'Z') ||
+	     (path[1] >= 'a' && path[1] <= 'z')) &&
+	    path[2] == ':') {
+		path++;
+	}
+#endif
+
+	return path;
+}
+
 static int parse_endpoint(const char *s, struct endpoint *ep)
 {
 	const char *path;
@@ -228,6 +245,25 @@ static int parse_endpoint(const char *s, struct endpoint *ep)
 		ep->kind = ENDPOINT_BUILTIN;
 		ep->builtin = builtin;
 
+		return 0;
+	} else if (strncmp(s, "file://", 7) == 0) {
+		const char *path = file_uri_path(s);
+
+		if (path[0] == '\0') {
+			LOG_ERROR("missing file:// path");
+			return -1;
+		}
+
+		if (strlen(path) >= sizeof(ep->path)) {
+			LOG_ERROR("file:// path too long",
+				"max", _LOGV(sizeof(ep->path) - 1)
+			);
+			return -1;
+		}
+
+		ep->kind = ENDPOINT_FILE;
+		strcpy(ep->path, path);
+
 		return 0;
 	} else if (strncmp(s, "unix-dgram:", 11) == 0) {
 		path = s + 11;
diff --git a/src/route.c b/src/route.c
index 06f339c..2efcdc6 100644
--- a/src/route.c
+++ b/src/route.c
@@ -30,6 +30,10 @@ int endpoint_to_string(const struct endpoint *ep, char *buf, size_t buf_len)
 		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/route.h b/src/route.h
index e672512..7e5c5e3 100644
--- a/src/route.h
+++ b/src/route.h
@@ -31,6 +31,7 @@ enum endpoint_kind {
 	ENDPOINT_UNIX,
 	ENDPOINT_UNIX_DGRAM,
 	ENDPOINT_BUILTIN,
+	ENDPOINT_FILE,
 };
 
 struct endpoint {
diff --git a/src/tcp_route.c b/src/tcp_route.c
index a65ef99..ce46022 100644
--- a/src/tcp_route.c
+++ b/src/tcp_route.c
@@ -20,6 +20,8 @@
 #define BEV_READ_HIGH_WATER (256 * 1024)
 #define BEV_WRITE_RESUME_WATER (128 * 1024)
 
+#define FILE_CHUNK_SIZE 4096
+
 struct tcp_route_ctx {
 	struct event_base *accept_base;
 	struct worker *worker;
@@ -38,6 +40,11 @@ typedef struct conn_s {
 	socklen_t peer_addr_len;
 } conn_t;
 
+struct file_conn {
+	conn_t *conn;
+	FILE *fp;
+};
+
 struct accepted_client {
 	evutil_socket_t fd;
 	struct sockaddr_storage peer_addr;
@@ -304,6 +311,118 @@ static void builtin_close_after_write_cb(struct bufferevent *bev, void *arg)
 	}
 }
 
+static void file_done(struct file_conn *fc)
+{
+	if (fc == NULL) {
+		return;
+	}
+
+	if (fc->fp != NULL) {
+		fclose(fc->fp);
+		fc->fp = NULL;
+	}
+
+	free_conn(fc->conn);
+	free(fc);
+}
+
+static void file_event_cb(struct bufferevent *bev, short events, void *arg)
+{
+	struct file_conn *fc = arg;
+
+	(void)bev;
+	(void)events;
+
+	file_done(fc);
+}
+
+static void file_write_cb(struct bufferevent *bev, void *arg)
+{
+	struct file_conn *fc = arg;
+	struct evbuffer *out = bufferevent_get_output(bev);
+	char buf[FILE_CHUNK_SIZE];
+
+	while (evbuffer_get_length(out) < BEV_READ_HIGH_WATER) {
+		size_t n = fread(buf, 1, sizeof(buf), fc->fp);
+
+		if (n > 0) {
+			if (bufferevent_write(bev, buf, n) < 0) {
+				file_done(fc);
+				return;
+			}
+
+			continue;
+		}
+
+		if (ferror(fc->fp)) {
+			file_done(fc);
+			return;
+		}
+
+		/*
+		 * EOF. If nothing is queued anymore, close now.
+		 * Otherwise keep the write callback installed so we close
+		 * after libevent drains the remaining output.
+		 */
+		if (evbuffer_get_length(out) == 0) {
+			file_done(fc);
+		}
+
+		return;
+	}
+}
+
+static int start_tcp_file(conn_t *conn)
+{
+	const struct route *r;
+	struct file_conn *fc;
+	FILE *fp;
+
+	if (conn == NULL || conn->route == NULL) {
+		return -EINVAL;
+	}
+
+	r = conn->route;
+
+	fp = fopen(r->upstream.path, "rb");
+	if (fp == NULL) {
+		int err = errno;
+
+		LOG_ERROR("failed to open file upstream",
+			"path", _LOGV(r->upstream.path),
+			"err", _LOGV(strerror(err))
+		);
+
+		free_conn(conn);
+		return -err;
+	}
+
+	if (conn->upstream != NULL) {
+		bufferevent_free(conn->upstream);
+		conn->upstream = NULL;
+	}
+
+	fc = calloc(1, sizeof(*fc));
+	if (fc == NULL) {
+		fclose(fp);
+		free_conn(conn);
+		return -ENOMEM;
+	}
+
+	fc->conn = conn;
+	fc->fp = fp;
+
+	bufferevent_setwatermark(conn->client, EV_WRITE, 0, BEV_READ_HIGH_WATER);
+	bufferevent_setcb(conn->client, NULL, file_write_cb, file_event_cb, fc);
+	bufferevent_enable(conn->client, EV_WRITE);
+
+	set_client_idle_timeout(conn, r);
+
+	file_write_cb(conn->client, fc);
+
+	return 0;
+}
+
 static int start_tcp_builtin(conn_t *conn)
 {
 	struct x_builtin_request req;
@@ -390,6 +509,11 @@ static void worker_adopt_client_fd(struct worker *w, struct accepted_client *ac)
 		return;
 	}
 
+	if (ac->route->upstream.kind == ENDPOINT_FILE) {
+		start_tcp_file(conn);
+		return;
+	}
+
 	conn->upstream = bufferevent_socket_new(w->base, -1, BEV_OPT_CLOSE_ON_FREE);
 	if (conn->upstream == NULL) {
 		free_conn(conn);
diff --git a/src/tinyproxy.c b/src/tinyproxy.c
index 2c3d6f2..3e3a00f 100644
--- a/src/tinyproxy.c
+++ b/src/tinyproxy.c
@@ -24,8 +24,24 @@ static void usage(FILE *out, const char *prog)
 		prog);
 }
 
+static void prevent_socket_write_from_killing_process(void)
+{
+#ifndef _WIN32
+	/*
+	 * On Unix, writing to a closed socket may raise SIGPIPE.
+	 * The default SIGPIPE action is process termination.
+	 *
+	 * Proxies must treat closed peers as ordinary I/O errors,
+	 * not as a reason to kill the whole daemon.
+	 */
+	signal(SIGPIPE, SIG_IGN);
+#endif
+}
+
 int main(int argc, char **argv)
 {
+	prevent_socket_write_from_killing_process();
+
 	const char *conf_path = "tinyproxy.conf";
 	int opt;
 	int exit_code = 1;
diff --git a/tinyproxy.conf b/tinyproxy.conf
index f042f9d..3112617 100644
--- a/tinyproxy.conf
+++ b/tinyproxy.conf
@@ -30,3 +30,6 @@
 :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