penguin/tinyproxy

An L4 proxy designed to act as a tiny transparent shim

commit 3f6d5bee8e59da43de308324b0db25752100b24b

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-06-07T23:35:56Z
subjectAdded sni_sniff option for diagnostics
commit 3f6d5bee8e59da43de308324b0db25752100b24b
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-06-07T23:35:56Z

    Added sni_sniff option for diagnostics
---
 docs/tinyproxy.conf.5.scd |  12 ++
 src/file_conf.c           |   1 +
 src/route.h               |   1 +
 src/stream_conn.c         |  11 +-
 src/stream_pipe.c         |  11 +-
 src/stream_route.c        |   1 +
 src/stream_route.h        |   3 +
 src/stream_sniff.c        | 412 ++++++++++++++++++++++++++++++++++++++++++++++
 src/stream_sniff.h        |  37 +++++
 9 files changed, 486 insertions(+), 3 deletions(-)

diff --git a/docs/tinyproxy.conf.5.scd b/docs/tinyproxy.conf.5.scd
index 7d0c36a..c9d9fba 100644
--- a/docs/tinyproxy.conf.5.scd
+++ b/docs/tinyproxy.conf.5.scd
@@ -22,6 +22,18 @@ proxy_v2
 	Enable proxy v2 for upstream.
 	Default: false
 
+sni_sniff
+	Enable passive SNI sniffing for TLS traffic.
+
+	When enabled, tinyproxy inspects the TLS ClientHello from the client
+	and logs the observed Server Name Indication as client_sni=.
+
+	This does not decrypt TLS traffic, modify client data, or verify that
+	the SNI value is truthful. If no usable SNI is observed, client_sni=
+	is left empty and sni_status= describes why.
+
+	Default: false
+
 keep_alive
 	Enable keep alive for tcp.
 	Default: false
diff --git a/src/file_conf.c b/src/file_conf.c
index ccef7a2..0826d2c 100644
--- a/src/file_conf.c
+++ b/src/file_conf.c
@@ -309,6 +309,7 @@ static int parse_route_options(char *s, struct route_options *opts)
 
 		PARSE_BOOL_OPT("proxy_v2", proxy_v2);
 		PARSE_BOOL_OPT("keep_alive", keep_alive);
+		PARSE_BOOL_OPT("sni_sniff", sni_sniff);
 
 		if (strcmp(tok, "broadcast_reply") == 0) {
 			opts->broadcast_reply = BROADCAST_REPLY_LISTEN;
diff --git a/src/route.h b/src/route.h
index e75b284..7a022d8 100644
--- a/src/route.h
+++ b/src/route.h
@@ -27,6 +27,7 @@ enum broadcast_reply_mode {
 struct route_options {
 	bool proxy_v2;
 	bool keep_alive;
+	bool sni_sniff;
 
 	int idle_timeout_sec;
 	int connect_timeout_sec;
diff --git a/src/stream_conn.c b/src/stream_conn.c
index 9891c34..85f1dd0 100644
--- a/src/stream_conn.c
+++ b/src/stream_conn.c
@@ -11,6 +11,7 @@
 #include "stream_file.h"
 #include "stream_builtin.h"
 #include "stream_pipe.h"
+#include "stream_sniff.h"
 #include "proxy_proto_v2.h"
 
 void free_conn(conn_t *conn) {
@@ -232,12 +233,20 @@ void stream_upstream_event_cb(struct bufferevent *bev, short events, void *arg)
 	}
 
 	if (events & BEV_EVENT_TIMEOUT) {
+		const char *client_sni = "";
+
+		if (r->opts.sni_sniff) {
+			client_sni = stream_sniff_log_sni(&conn->sniff);
+		}
+
 		LOG_WARN("upstream connection timed out",
 			"listen", _LOGV_ENDPOINT(&r->listen),
 			"upstream", _LOGV_ENDPOINT(&r->upstream),
 			"client_addr", _LOGV_SOCKADDR(&conn->peer_addr, conn->peer_addr_len,
-				peer_buf, sizeof(peer_buf))
+				peer_buf, sizeof(peer_buf)),
+			"client_sni", _LOGV(client_sni)
 		);
+
 		goto out_free;
 	}
 
diff --git a/src/stream_pipe.c b/src/stream_pipe.c
index daa2cdc..7565a38 100644
--- a/src/stream_pipe.c
+++ b/src/stream_pipe.c
@@ -4,18 +4,21 @@
 #include "route.h"
 #include "stream_conn.h"
 #include "stream_pipe.h"
+#include "stream_sniff.h"
 
 void pipe_client_read_cb(struct bufferevent *client, void *arg)
 {
 	conn_t *conn = arg;
 	struct bufferevent *upstream = conn->upstream;
+	struct evbuffer *src;
+	struct evbuffer *dst;
 
 	if (upstream == NULL) {
 		return;
 	}
 
-	struct evbuffer *src = bufferevent_get_input(client);
-	struct evbuffer *dst = bufferevent_get_output(upstream);
+	src = bufferevent_get_input(client);
+	dst = bufferevent_get_output(upstream);
 
 #ifdef TINYPROXY_DEBUG
 	size_t len = evbuffer_get_length(src);
@@ -28,6 +31,10 @@ void pipe_client_read_cb(struct bufferevent *client, void *arg)
 	);
 #endif
 
+	if (conn->route->opts.sni_sniff) {
+		stream_sniff_peek_client_input(&conn->sniff, src);
+	}
+
 	evbuffer_add_buffer(dst, src);
 
 	if (evbuffer_get_length(dst) >= BEV_READ_HIGH_WATER) {
diff --git a/src/stream_route.c b/src/stream_route.c
index 69670b5..92172da 100644
--- a/src/stream_route.c
+++ b/src/stream_route.c
@@ -8,6 +8,7 @@
 #include "worker.h"
 #include "route.h"
 #include "stream_listener.h"
+#include "stream_sniff.h"
 
 int start_stream_route(
 		struct event_base *accept_base,
diff --git a/src/stream_route.h b/src/stream_route.h
index 1b67405..30fec74 100644
--- a/src/stream_route.h
+++ b/src/stream_route.h
@@ -6,6 +6,7 @@
 #include <event2/util.h>
 
 #include "compat.h"
+#include "stream_sniff.h"
 
 struct worker;
 struct route;
@@ -29,6 +30,8 @@ typedef struct conn_s {
 	struct sockaddr_storage peer_addr;
 	socklen_t peer_addr_len;
 
+	struct stream_sniff sniff;
+
 	bool close_after_client_eof;
 	bool close_client_after_drain;
 } conn_t;
diff --git a/src/stream_sniff.c b/src/stream_sniff.c
new file mode 100644
index 0000000..c88a5d3
--- /dev/null
+++ b/src/stream_sniff.c
@@ -0,0 +1,412 @@
+#include "stream_sniff.h"
+
+#include <ctype.h>
+#include <stdint.h>
+#include <string.h>
+
+#include "klog.h"
+
+static uint16_t read_u16_be(const unsigned char *p)
+{
+	return ((uint16_t)p[0] << 8) | (uint16_t)p[1];
+}
+
+static uint32_t read_u24_be(const unsigned char *p)
+{
+	return ((uint32_t)p[0] << 16) |
+		((uint32_t)p[1] << 8) |
+		(uint32_t)p[2];
+}
+
+static bool valid_sni_name(const unsigned char *p, size_t len)
+{
+	size_t i;
+
+	if (len == 0 || len > STREAM_SNI_MAX) {
+		return false;
+	}
+
+	/*
+	 * Keep validation conservative but not overly clever.
+	 *
+	 * SNI host_name is supposed to be a DNS hostname, not an arbitrary
+	 * string. Do not accept NUL/control bytes, slashes, spaces, etc.
+	 */
+	for (i = 0; i < len; i++) {
+		unsigned char c = p[i];
+
+		if (isalnum(c) || c == '-' || c == '.') {
+			continue;
+		}
+
+		return false;
+	}
+
+	return true;
+}
+
+static enum stream_sniff_status parse_sni_from_client_hello(
+	const unsigned char *buf,
+	size_t len,
+	char *out,
+	size_t out_len,
+	bool *need_more)
+{
+	size_t pos;
+	size_t record_len;
+	size_t record_end;
+	size_t hs_len;
+	size_t hs_end;
+	size_t session_id_len;
+	size_t cipher_suites_len;
+	size_t compression_methods_len;
+	size_t extensions_len;
+	size_t extensions_end;
+
+	*need_more = false;
+
+	if (len == 0) {
+		*need_more = true;
+		return STREAM_SNIFF_NEED_MORE;
+	}
+
+	/*
+	 * TLS record header:
+	 *
+	 *   content_type:     1 byte  == 22 handshake
+	 *   legacy_version:   2 bytes
+	 *   length:           2 bytes
+	 */
+	if (buf[0] != 0x16) {
+		return STREAM_SNIFF_NOT_TLS;
+	}
+
+	if (len < 5) {
+		*need_more = true;
+		return STREAM_SNIFF_NEED_MORE;
+	}
+
+	/*
+	 * TLS major version should be 3 for SSLv3/TLS 1.x records.
+	 * TLS 1.3 ClientHello commonly still uses legacy record version 0x0301
+	 * or 0x0303, so do not require an exact minor version.
+	 */
+	if (buf[1] != 0x03) {
+		return STREAM_SNIFF_PARSE_ERROR;
+	}
+
+	record_len = read_u16_be(buf + 3);
+	record_end = 5 + record_len;
+
+	if (record_len == 0) {
+		return STREAM_SNIFF_PARSE_ERROR;
+	}
+
+	if (record_end > len) {
+		*need_more = true;
+		return STREAM_SNIFF_NEED_MORE;
+	}
+
+	pos = 5;
+
+	/*
+	 * Handshake message:
+	 *
+	 *   handshake_type:   1 byte  == 1 ClientHello
+	 *   length:           3 bytes
+	 */
+	if (pos + 4 > record_end) {
+		return STREAM_SNIFF_PARSE_ERROR;
+	}
+
+	if (buf[pos] != 0x01) {
+		return STREAM_SNIFF_NOT_TLS;
+	}
+
+	hs_len = read_u24_be(buf + pos + 1);
+	pos += 4;
+	hs_end = pos + hs_len;
+
+	if (hs_len == 0 || hs_end > record_end) {
+		return STREAM_SNIFF_PARSE_ERROR;
+	}
+
+	/*
+	 * ClientHello body:
+	 *
+	 *   legacy_version:              2 bytes
+	 *   random:                     32 bytes
+	 *   session_id_len:              1 byte
+	 *   session_id:                  variable
+	 *   cipher_suites_len:           2 bytes
+	 *   cipher_suites:               variable
+	 *   compression_methods_len:      1 byte
+	 *   compression_methods:          variable
+	 *   extensions_len:               2 bytes
+	 *   extensions:                   variable
+	 */
+	if (pos + 2 + 32 + 1 > hs_end) {
+		return STREAM_SNIFF_PARSE_ERROR;
+	}
+
+	pos += 2;  /* legacy_version */
+	pos += 32; /* random */
+
+	session_id_len = buf[pos];
+	pos += 1;
+
+	if (pos + session_id_len > hs_end) {
+		return STREAM_SNIFF_PARSE_ERROR;
+	}
+	pos += session_id_len;
+
+	if (pos + 2 > hs_end) {
+		return STREAM_SNIFF_PARSE_ERROR;
+	}
+
+	cipher_suites_len = read_u16_be(buf + pos);
+	pos += 2;
+
+	if (cipher_suites_len == 0 || (cipher_suites_len % 2) != 0) {
+		return STREAM_SNIFF_PARSE_ERROR;
+	}
+
+	if (pos + cipher_suites_len > hs_end) {
+		return STREAM_SNIFF_PARSE_ERROR;
+	}
+	pos += cipher_suites_len;
+
+	if (pos + 1 > hs_end) {
+		return STREAM_SNIFF_PARSE_ERROR;
+	}
+
+	compression_methods_len = buf[pos];
+	pos += 1;
+
+	if (compression_methods_len == 0) {
+		return STREAM_SNIFF_PARSE_ERROR;
+	}
+
+	if (pos + compression_methods_len > hs_end) {
+		return STREAM_SNIFF_PARSE_ERROR;
+	}
+	pos += compression_methods_len;
+
+	/*
+	 * No extensions means no SNI.
+	 */
+	if (pos == hs_end) {
+		return STREAM_SNIFF_MISSING;
+	}
+
+	if (pos + 2 > hs_end) {
+		return STREAM_SNIFF_PARSE_ERROR;
+	}
+
+	extensions_len = read_u16_be(buf + pos);
+	pos += 2;
+
+	extensions_end = pos + extensions_len;
+	if (extensions_end != hs_end) {
+		return STREAM_SNIFF_PARSE_ERROR;
+	}
+
+	while (pos + 4 <= extensions_end) {
+		uint16_t ext_type;
+		uint16_t ext_len;
+		size_t ext_data;
+		size_t ext_end;
+
+		ext_type = read_u16_be(buf + pos);
+		ext_len = read_u16_be(buf + pos + 2);
+		pos += 4;
+
+		ext_data = pos;
+		ext_end = ext_data + ext_len;
+
+		if (ext_end > extensions_end) {
+			return STREAM_SNIFF_PARSE_ERROR;
+		}
+
+		/*
+		 * Extension type 0 = server_name.
+		 */
+		if (ext_type == 0) {
+			size_t list_pos;
+			size_t list_len;
+			size_t list_end;
+
+			if (ext_len < 2) {
+				return STREAM_SNIFF_PARSE_ERROR;
+			}
+
+			list_len = read_u16_be(buf + ext_data);
+			list_pos = ext_data + 2;
+			list_end = list_pos + list_len;
+
+			if (list_end != ext_end) {
+				return STREAM_SNIFF_PARSE_ERROR;
+			}
+
+			while (list_pos + 3 <= list_end) {
+				unsigned char name_type;
+				uint16_t name_len;
+				const unsigned char *name;
+
+				name_type = buf[list_pos];
+				name_len = read_u16_be(buf + list_pos + 1);
+				list_pos += 3;
+
+				if (list_pos + name_len > list_end) {
+					return STREAM_SNIFF_PARSE_ERROR;
+				}
+
+				name = buf + list_pos;
+
+				/*
+				 * name_type 0 = host_name.
+				 */
+				if (name_type == 0) {
+					if (name_len > STREAM_SNI_MAX) {
+						return STREAM_SNIFF_NAME_TOO_LONG;
+					}
+
+					if (!valid_sni_name(name, name_len)) {
+						return STREAM_SNIFF_PARSE_ERROR;
+					}
+
+					if (out_len < (size_t)name_len + 1) {
+						return STREAM_SNIFF_NAME_TOO_LONG;
+					}
+
+					memcpy(out, name, name_len);
+					out[name_len] = '\0';
+
+					return STREAM_SNIFF_PARSED;
+				}
+
+				list_pos += name_len;
+			}
+
+			return STREAM_SNIFF_MISSING;
+		}
+
+		pos = ext_end;
+	}
+
+	if (pos != extensions_end) {
+		return STREAM_SNIFF_PARSE_ERROR;
+	}
+
+	return STREAM_SNIFF_MISSING;
+}
+
+void stream_sniff_peek_client_input(
+	struct stream_sniff *s,
+	struct evbuffer *input)
+{
+	unsigned char buf[STREAM_SNIFF_MAX];
+	size_t input_len;
+	size_t copy_len;
+	ssize_t n;
+	bool need_more = false;
+	enum stream_sniff_status status;
+
+	if (s == NULL || input == NULL || s->done) {
+		return;
+	}
+
+	input_len = evbuffer_get_length(input);
+	if (input_len == 0) {
+		return;
+	}
+
+	copy_len = input_len;
+	if (copy_len > STREAM_SNIFF_MAX) {
+		copy_len = STREAM_SNIFF_MAX;
+	}
+
+	n = evbuffer_copyout(input, buf, copy_len);
+	if (n <= 0) {
+		return;
+	}
+
+	s->observed = (size_t)n;
+
+	status = parse_sni_from_client_hello(
+		buf,
+		(size_t)n,
+		s->sni,
+		sizeof(s->sni),
+		&need_more
+	);
+
+	if (status == STREAM_SNIFF_NEED_MORE && need_more) {
+		if (input_len >= STREAM_SNIFF_MAX) {
+			s->status = STREAM_SNIFF_TRUNCATED;
+			s->done = true;
+			return;
+		}
+
+		s->status = STREAM_SNIFF_NEED_MORE;
+		return;
+	}
+
+	s->status = status;
+
+	switch (status) {
+	case STREAM_SNIFF_PARSED:
+	case STREAM_SNIFF_MISSING:
+	case STREAM_SNIFF_NOT_TLS:
+	case STREAM_SNIFF_TRUNCATED:
+	case STREAM_SNIFF_NAME_TOO_LONG:
+	case STREAM_SNIFF_PARSE_ERROR:
+		s->done = true;
+		break;
+
+	case STREAM_SNIFF_NOT_OBSERVED:
+	case STREAM_SNIFF_NEED_MORE:
+	default:
+		break;
+	}
+
+	if (s->done) {
+		LOG_DEBUG("sni sniff",
+			"sni", _LOGV(s->sni),
+			"status", _LOGV(stream_sniff_status_str(s->status)),
+			"observed", _LOGV(s->observed)
+		);
+	}
+}
+
+const char *stream_sniff_log_sni(const struct stream_sniff *s)
+{
+	if (s == NULL) {
+		return "<not_observed>";
+	}
+
+	if (s->status == STREAM_SNIFF_PARSED && s->sni[0] != '\0') {
+		return s->sni;
+	}
+
+	switch (s->status) {
+	case STREAM_SNIFF_NOT_OBSERVED:
+		return "<not_observed>";
+	case STREAM_SNIFF_NEED_MORE:
+		return "<need_more>";
+	case STREAM_SNIFF_MISSING:
+		return "<missing>";
+	case STREAM_SNIFF_NOT_TLS:
+		return "<not_tls>";
+	case STREAM_SNIFF_TRUNCATED:
+		return "<truncated>";
+	case STREAM_SNIFF_NAME_TOO_LONG:
+		return "<name_too_long>";
+	case STREAM_SNIFF_PARSE_ERROR:
+		return "<parse_error>";
+	case STREAM_SNIFF_PARSED:
+		return "<empty>";
+	default:
+		return "<unknown>";
+	}
+}
diff --git a/src/stream_sniff.h b/src/stream_sniff.h
new file mode 100644
index 0000000..9e8bb34
--- /dev/null
+++ b/src/stream_sniff.h
@@ -0,0 +1,37 @@
+#ifndef STREAM_SNIFF_H
+#define STREAM_SNIFF_H
+
+#include <stdbool.h>
+#include <stddef.h>
+
+#include <event2/buffer.h>
+
+#define STREAM_SNIFF_MAX 8192
+#define STREAM_SNI_MAX 253
+
+enum stream_sniff_status {
+	STREAM_SNIFF_NOT_OBSERVED = 0,
+	STREAM_SNIFF_NEED_MORE,
+	STREAM_SNIFF_PARSED,
+	STREAM_SNIFF_MISSING,
+	STREAM_SNIFF_NOT_TLS,
+	STREAM_SNIFF_TRUNCATED,
+	STREAM_SNIFF_NAME_TOO_LONG,
+	STREAM_SNIFF_PARSE_ERROR,
+};
+
+struct stream_sniff {
+	char sni[STREAM_SNI_MAX + 1];
+	enum stream_sniff_status status;
+	size_t observed;
+	bool done;
+};
+
+void stream_sniff_peek_client_input(
+	struct stream_sniff *s,
+	struct evbuffer *input
+);
+
+const char *stream_sniff_log_sni(const struct stream_sniff *s);
+
+#endif