penguin/tinyproxy

An L4 proxy designed to act as a tiny transparent shim

commit 77ca1751959473007edd0e798061f62b4e79f0ce

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-05-31T17:11:44Z
subjectAdded broadcast_reply=upstream mode
commit 77ca1751959473007edd0e798061f62b4e79f0ce
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-05-31T17:11:44Z

    Added broadcast_reply=upstream mode
---
 README.md                           |   2 +-
 docs/tinyproxy.conf.5.scd           |  13 +++-
 examples/minecraft-bedrock-lan.conf |  12 +++-
 src/datagram_client.c               |  60 ++++++++++++++-----
 src/datagram_listener.c             |  21 +------
 src/datagram_raw.c                  | 114 +++++++++++-------------------------
 src/datagram_raw.h                  |   8 +--
 src/datagram_route.c                |  13 ++--
 src/file_conf.c                     |  41 ++++++++++++-
 src/route.c                         |  11 ++++
 src/route.h                         |   9 ++-
 11 files changed, 175 insertions(+), 129 deletions(-)

diff --git a/README.md b/README.md
index 8432933..9ba0734 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ Makes a remote Minecraft Bedrock server appear as a LAN server.
 
 ```sh
 tinyproxy \
-    -L "udp :19132 udp 123.123.123.123:19132 broadcast_reply"
+    -L "udp :19132 udp 123.123.123.123:19132 broadcast_reply=listen"
 ```
 
 Run with config file:
diff --git a/docs/tinyproxy.conf.5.scd b/docs/tinyproxy.conf.5.scd
index fc40c92..46490b9 100644
--- a/docs/tinyproxy.conf.5.scd
+++ b/docs/tinyproxy.conf.5.scd
@@ -34,8 +34,17 @@ connect_timeout=SECONDS
 	Connection timeout for both listen and upstream.
 	Default: 5
 
-broadcast_reply
-	UDP only. Forward broadcast packets to the upstream and relay replies from the listening endpoint.
+broadcast_reply=listen
+    UDP only. Send upstream replies back to the original client from the listening endpoint.
+
+broadcast_reply=upstream
+    UDP/IPv4 only. Forward broadcast requests to the configured upstream and
+    send upstream replies back to the original client using raw IPv4 packets
+    whose source address and port are the upstream endpoint.
+
+    This lets broadcast-based discovery advertise the upstream address directly,
+    so clients can connect to the upstream after discovery instead of sending
+    gameplay traffic through tinyproxy. Linux only; requires CAP_NET_RAW.
 
 # ENDPOINTS
 
diff --git a/examples/minecraft-bedrock-lan.conf b/examples/minecraft-bedrock-lan.conf
index 1067f51..468e5b9 100644
--- a/examples/minecraft-bedrock-lan.conf
+++ b/examples/minecraft-bedrock-lan.conf
@@ -10,4 +10,14 @@
 #
 # Replace 123.123.123.123:19132 with your server address.
 #
-udp :19132 udp 123.123.123.123:19132 broadcast_reply
+udp :19132 udp 123.123.123.123:19132 broadcast_reply=listen
+#
+# Advertise a Minecraft Bedrock server outside the local broadcast subnet.
+#
+# tinyproxy receives LAN discovery broadcasts on :19132, forwards them to
+# 10.0.1.123:19132, then sends the discovery reply back as if it came from
+# 10.0.1.123:19132.
+#
+# After discovery, the Minecraft client connects directly to 10.0.1.123:19132
+# for gameplay traffic, so tinyproxy only handles discovery.
+udp :19132 udp 10.0.1.123:19132 broadcast_reply=upstream
diff --git a/src/datagram_client.c b/src/datagram_client.c
index 09b65cd..3ec38ac 100644
--- a/src/datagram_client.c
+++ b/src/datagram_client.c
@@ -8,6 +8,7 @@
 #include "env.h"
 #include "route.h"
 #include "datagram_client.h"
+#include "datagram_raw.h"
 #include "proxy_proto_v2.h"
 
 void free_datagram_client(struct datagram_client *c)
@@ -262,6 +263,44 @@ int send_datagram_payload_to_upstream(
 	return 0;
 }
 
+static int send_datagram_payload_to_client(
+	struct datagram_client *c,
+	const unsigned char *data,
+	size_t data_len)
+{
+	struct datagram_route_ctx *ctx = c->ctx;
+	ssize_t sent;
+
+	if (ctx->route->opts.broadcast_reply == BROADCAST_REPLY_UPSTREAM) {
+		return datagram_raw_send_udp_ipv4(
+			ctx->raw_fd,
+			&ctx->route->upstream,
+			(const struct sockaddr *)&c->client_addr,
+			c->client_addr_len,
+			data,
+			data_len);
+	}
+
+	sent = sendto(
+		ctx->listen_fd,
+		(const char *)data,
+		data_len,
+		0,
+		(const struct sockaddr *)&c->client_addr,
+		c->client_addr_len
+	);
+
+	if (sent < 0) {
+		return -EVUTIL_SOCKET_ERROR();
+	}
+
+	if ((size_t)sent != data_len) {
+		return -EIO;
+	}
+
+	return 0;
+}
+
 static void upstream_read_cb(evutil_socket_t fd, short events, void *arg)
 {
 	struct datagram_client *c = arg;
@@ -273,7 +312,10 @@ static void upstream_read_cb(evutil_socket_t fd, short events, void *arg)
 	compat_mutex_lock(&ctx->clients_mu);
 
 	for (;;) {
-		ssize_t n = recv(fd, (char *)buf, sizeof(buf), 0);
+		ssize_t n;
+		int rc;
+
+		n = recv(fd, (char *)buf, sizeof(buf), 0);
 		if (n < 0) {
 			int err = EVUTIL_SOCKET_ERROR();
 
@@ -291,20 +333,10 @@ static void upstream_read_cb(evutil_socket_t fd, short events, void *arg)
 			goto out;
 		}
 
-		ssize_t sent = sendto(
-			ctx->listen_fd,
-			(const char *)buf,
-			(size_t)n,
-			0,
-			(const struct sockaddr *)&c->client_addr,
-			c->client_addr_len
-		);
-
-		if (sent < 0) {
-			int err = EVUTIL_SOCKET_ERROR();
-
+		rc = send_datagram_payload_to_client(c, buf, (size_t)n);
+		if (rc != 0) {
 			LOG_ERROR("udp send to client failed",
-				"err", _LOGV(evutil_socket_error_to_string(err))
+				"err", _LOGV(strerror(-rc))
 			);
 			goto out;
 		}
diff --git a/src/datagram_listener.c b/src/datagram_listener.c
index f862c44..1b0cc8b 100644
--- a/src/datagram_listener.c
+++ b/src/datagram_listener.c
@@ -97,23 +97,6 @@ int datagram_route_handle_packet(
 		return handle_datagram_builtin_packet(pkt);
 	}
 
-	if (ctx->route->opts.transparent_replay) {
-		rc = datagram_raw_replay_ipv4(
-				ctx->raw_fd,
-				ctx->route,
-				(const struct sockaddr *)&pkt->peer_addr,
-				pkt->peer_addr_len,
-				pkt->data,
-				pkt->data_len);
-
-		if (rc != 0) {
-			LOG_WARN("failed to replay datagram",
-					"err", _LOGV(strerror(-rc)));
-		}
-
-		return rc;
-	}
-
 	compat_mutex_lock(&ctx->clients_mu);
 
 	c = datagram_route_get_or_create_client(w, pkt);
@@ -265,7 +248,7 @@ static int bind_unix_datagram_listener(struct datagram_route_ctx *ctx)
 		return -err;
 	}
 
-	if (r->opts.broadcast_reply) {
+	if (r->opts.broadcast_reply != BROADCAST_REPLY_OFF) {
 		LOG_ERROR("broadcast_reply is only valid for udp inet listeners",
 				"line", _LOGV(r->line_no));
 		return -EINVAL;
@@ -373,7 +356,7 @@ static int bind_udp_datagram_listener(struct datagram_route_ctx *ctx)
 		return -err;
 	}
 
-	if (r->opts.broadcast_reply) {
+	if (r->opts.broadcast_reply != BROADCAST_REPLY_OFF) {
 		int yes = 1;
 		if (setsockopt(ctx->listen_fd, SOL_SOCKET, SO_BROADCAST,
 					(const char *)&yes, sizeof(yes)) < 0) {
diff --git a/src/datagram_raw.c b/src/datagram_raw.c
index 53adb5a..f5ec6a0 100644
--- a/src/datagram_raw.c
+++ b/src/datagram_raw.c
@@ -39,60 +39,6 @@ static uint16_t checksum16(const void *data, size_t len)
 
 #ifndef _WIN32
 
-struct udp4_pseudo_header {
-	uint32_t src;
-	uint32_t dst;
-	uint8_t zero;
-	uint8_t proto;
-	uint16_t len;
-};
-
-static uint16_t udp4_checksum(
-	const struct ip *ip,
-	const struct udphdr *udp,
-	const unsigned char *payload,
-	size_t payload_len)
-{
-	struct udp4_pseudo_header ph;
-	size_t udp_len = sizeof(*udp) + payload_len;
-	size_t total_len = sizeof(ph) + udp_len;
-	unsigned char *buf;
-	uint16_t sum;
-
-	if (udp_len > UINT16_MAX || total_len > SIZE_MAX) {
-		return 0;
-	}
-
-	buf = malloc(total_len);
-	if (!buf) {
-		return 0;
-	}
-
-	memset(&ph, 0, sizeof(ph));
-	ph.src = ip->ip_src.s_addr;
-	ph.dst = ip->ip_dst.s_addr;
-	ph.zero = 0;
-	ph.proto = IPPROTO_UDP;
-	ph.len = htons((uint16_t)udp_len);
-
-	memcpy(buf, &ph, sizeof(ph));
-	memcpy(buf + sizeof(ph), udp, sizeof(*udp));
-	memcpy(buf + sizeof(ph) + sizeof(*udp), payload, payload_len);
-
-	sum = checksum16(buf, total_len);
-	free(buf);
-
-	/*
-	 * For IPv4 UDP, checksum 0 means "no checksum".
-	 * If the computed checksum is 0, transmit it as 0xffff.
-	 */
-	if (sum == 0) {
-		sum = 0xffff;
-	}
-
-	return sum;
-}
-
 int datagram_raw_open_ipv4(evutil_socket_t *out_fd)
 {
 	evutil_socket_t fd;
@@ -140,16 +86,16 @@ static int endpoint_to_sockaddr_in(
 	return 0;
 }
 
-int datagram_raw_replay_ipv4(
+int datagram_raw_send_udp_ipv4(
 	evutil_socket_t raw_fd,
-	const struct route *r,
-	const struct sockaddr *peer_addr,
-	socklen_t peer_addr_len,
+	const struct endpoint *src_ep,
+	const struct sockaddr *dst_addr,
+	socklen_t dst_addr_len,
 	const unsigned char *data,
 	size_t data_len)
 {
-	const struct sockaddr_in *src;
-	struct sockaddr_in upstream_addr;
+	struct sockaddr_in src_addr;
+	const struct sockaddr_in *dst;
 	struct sockaddr_in send_dst;
 	unsigned char *packet;
 	struct ip *ip;
@@ -160,22 +106,24 @@ int datagram_raw_replay_ipv4(
 	ssize_t n;
 	int rc;
 
-	(void)peer_addr_len;
+	if (raw_fd < 0 || !src_ep || !dst_addr || !data) {
+		return -EINVAL;
+	}
 
-	if (raw_fd < 0 || !r || !peer_addr || !data) {
+	if (dst_addr_len < (socklen_t)sizeof(struct sockaddr_in)) {
 		return -EINVAL;
 	}
 
-	if (peer_addr->sa_family != AF_INET) {
+	if (dst_addr->sa_family != AF_INET) {
 		return -EAFNOSUPPORT;
 	}
 
-	rc = endpoint_to_sockaddr_in(&r->upstream, &upstream_addr);
+	rc = endpoint_to_sockaddr_in(src_ep, &src_addr);
 	if (rc != 0) {
 		return rc;
 	}
 
-	src = (const struct sockaddr_in *)peer_addr;
+	dst = (const struct sockaddr_in *)dst_addr;
 
 	if (data_len > UINT16_MAX - ip_len - udp_len) {
 		return -EMSGSIZE;
@@ -199,24 +147,30 @@ int datagram_raw_replay_ipv4(
 	ip->ip_off = 0;
 	ip->ip_ttl = 64;
 	ip->ip_p = IPPROTO_UDP;
-	ip->ip_src = src->sin_addr;
-	ip->ip_dst = upstream_addr.sin_addr;
+	ip->ip_src = src_addr.sin_addr;
+	ip->ip_dst = dst->sin_addr;
 	ip->ip_sum = 0;
-	ip->ip_sum = checksum16(ip, ip_len);
 
-	udp->uh_sport = src->sin_port;
-	udp->uh_dport = upstream_addr.sin_port;
+	udp->uh_sport = src_addr.sin_port;
+	udp->uh_dport = dst->sin_port;
 	udp->uh_ulen = htons((uint16_t)(udp_len + data_len));
 	udp->uh_sum = 0;
 
 	memcpy(packet + ip_len + udp_len, data, data_len);
 
-	udp->uh_sum = udp4_checksum(ip, udp, data, data_len);
+	ip->ip_sum = checksum16(ip, ip_len);
+
+	/*
+	 * For IPv4, UDP checksum 0 is legal and means "no checksum".
+	 * Keep this simple for now; it avoids checksum/offload weirdness while
+	 * testing spoofed discovery replies.
+	 */
+	udp->uh_sum = 0;
 
 	memset(&send_dst, 0, sizeof(send_dst));
 	send_dst.sin_family = AF_INET;
-	send_dst.sin_addr = upstream_addr.sin_addr;
-	send_dst.sin_port = upstream_addr.sin_port;
+	send_dst.sin_addr = dst->sin_addr;
+	send_dst.sin_port = dst->sin_port;
 
 	n = sendto(
 		raw_fd,
@@ -247,18 +201,18 @@ int datagram_raw_open_ipv4(evutil_socket_t *out_fd)
 	return -ENOTSUP;
 }
 
-int datagram_raw_replay_ipv4(
+int datagram_raw_send_udp_ipv4(
 	evutil_socket_t raw_fd,
-	const struct route *r,
-	const struct sockaddr *peer_addr,
-	socklen_t peer_addr_len,
+	const struct endpoint *src_ep,
+	const struct sockaddr *dst_addr,
+	socklen_t dst_addr_len,
 	const unsigned char *data,
 	size_t data_len)
 {
 	(void)raw_fd;
-	(void)r;
-	(void)peer_addr;
-	(void)peer_addr_len;
+	(void)src_ap;
+	(void)dst_addr;
+	(void)dst_addr_len;
 	(void)data;
 	(void)data_len;
 
diff --git a/src/datagram_raw.h b/src/datagram_raw.h
index 1255d22..bc4e5f7 100644
--- a/src/datagram_raw.h
+++ b/src/datagram_raw.h
@@ -9,11 +9,11 @@
 
 int datagram_raw_open_ipv4(evutil_socket_t *out_fd);
 
-int datagram_raw_replay_ipv4(
+int datagram_raw_send_udp_ipv4(
 	evutil_socket_t raw_fd,
-	const struct route *r,
-	const struct sockaddr *peer_addr,
-	socklen_t peer_addr_len,
+	const struct endpoint *src_ep,
+	const struct sockaddr *dst_addr,
+	socklen_t dst_addr_len,
 	const unsigned char *data,
 	size_t data_len);
 
diff --git a/src/datagram_route.c b/src/datagram_route.c
index 1a4f7eb..8e73a3b 100644
--- a/src/datagram_route.c
+++ b/src/datagram_route.c
@@ -27,10 +27,6 @@ static int prepare_datagram_route(struct datagram_route_ctx *ctx)
 		}
 	}
 
-	if (r->opts.transparent_replay) {
-		return datagram_raw_open_ipv4(&ctx->raw_fd);
-	}
-
 	return 0;
 }
 
@@ -53,7 +49,14 @@ int start_datagram_route(
 	ctx->worker_pool = wpool;
 	ctx->route = r;
 	ctx->listen_fd = EVUTIL_INVALID_SOCKET;
-	ctx->raw_fd = -1;
+	ctx->raw_fd = EVUTIL_INVALID_SOCKET;
+
+	if (r->opts.broadcast_reply == BROADCAST_REPLY_UPSTREAM) {
+		rc = datagram_raw_open_ipv4(&ctx->raw_fd);
+		if (rc != 0) {
+			return rc;
+		}
+	}
 
 	rc = compat_mutex_init(&ctx->clients_mu);
 	if (rc != 0) {
diff --git a/src/file_conf.c b/src/file_conf.c
index bd2bed9..8b6ae5a 100644
--- a/src/file_conf.c
+++ b/src/file_conf.c
@@ -150,6 +150,27 @@ static int parse_proto(const char *s, enum endpoint_proto *out)
 	return -1;
 }
 
+static int parse_broadcast_reply_mode(
+	const char *value,
+	enum broadcast_reply_mode *out)
+{
+	if (!value || !out) {
+		return -EINVAL;
+	}
+
+	if (strcmp(value, "listen") == 0) {
+		*out = BROADCAST_REPLY_LISTEN;
+		return 0;
+	}
+
+	if (strcmp(value, "upstream") == 0) {
+		*out = BROADCAST_REPLY_UPSTREAM;
+		return 0;
+	}
+
+	return -EINVAL;
+}
+
 static int parse_positive_int(const char *s, int *out)
 {
 	char *end = NULL;
@@ -192,6 +213,16 @@ static int parse_route_options(char *s, struct route_options *opts)
 		} \
 	} while (0)
 
+#define PARSE_ENUM_OPT(name, parser, field) do { \
+		if (strcmp(key, name) == 0) { \
+			int rc = parser(value, &opts->field); \
+			if (rc != 0) { \
+				return rc; \
+			} \
+			goto next_option; \
+		} \
+	} while (0)
+
 #define PARSE_BOOL_OPT(name, field) do { \
 		if (strcmp(tok, name) == 0) { \
 			opts->field = true; \
@@ -225,13 +256,20 @@ static int parse_route_options(char *s, struct route_options *opts)
 
 			PARSE_INT_OPT("idle_timeout", idle_timeout_sec);
 			PARSE_INT_OPT("connect_timeout", connect_timeout_sec);
+			PARSE_ENUM_OPT("broadcast_reply",
+				parse_broadcast_reply_mode,
+				broadcast_reply);
 
 			return -EINVAL;
 		}
 
 		PARSE_BOOL_OPT("proxy_v2", proxy_v2);
 		PARSE_BOOL_OPT("keep_alive", keep_alive);
-		PARSE_BOOL_OPT("broadcast_reply", broadcast_reply);
+
+		if (strcmp(tok, "broadcast_reply") == 0) {
+			opts->broadcast_reply = BROADCAST_REPLY_LISTEN;
+			goto next_option;
+		}
 
 		return -EINVAL;
 
@@ -240,6 +278,7 @@ next_option:
 	}
 
 #undef PARSE_BOOL_OPT
+#undef PARSE_ENUM_OPT
 #undef PARSE_INT_OPT
 
 	return 0;
diff --git a/src/route.c b/src/route.c
index fa517c0..57944f1 100644
--- a/src/route.c
+++ b/src/route.c
@@ -51,6 +51,17 @@ void route_options_str(const struct route_options *opts, char *buf, size_t bufle
 	ADD_INT("connect_timeout", opts->connect_timeout_sec);
 	ADD_BOOL("keep_alive", opts->keep_alive);
 
+	switch (opts->broadcast_reply) {
+		case BROADCAST_REPLY_OFF:
+			break;
+		case BROADCAST_REPLY_LISTEN:
+			ADD_FMT("%s=%s", "broadcast_reply", "listen");
+			break;
+		case BROADCAST_REPLY_UPSTREAM:
+			ADD_FMT("%s=%s", "broadcast_reply", "upstream");
+			break;
+	}
+
 #undef ADD_INT
 #undef ADD_BOOL
 #undef ADD_FMT
diff --git a/src/route.h b/src/route.h
index 24c3206..454796f 100644
--- a/src/route.h
+++ b/src/route.h
@@ -16,14 +16,19 @@
 
 #define UDP_MAX_PACKET 65535
 
+enum broadcast_reply_mode {
+	BROADCAST_REPLY_OFF = 0,
+	BROADCAST_REPLY_LISTEN,
+	BROADCAST_REPLY_UPSTREAM,
+};
+
 struct route_options {
 	bool proxy_v2;
 	bool keep_alive;
-	bool broadcast_reply;
-	bool transparent_replay;
 
 	int idle_timeout_sec;
 	int connect_timeout_sec;
+	enum broadcast_reply_mode broadcast_reply;
 };
 
 struct route {