From bc4f45cf91b2175ab3eccf40324f4a523f1331f9 Mon Sep 17 00:00:00 2001 From: Rob van Oostenrijk Date: Sun, 14 Jun 2026 10:51:32 +0400 Subject: [PATCH 1/5] Added TLS SNI proxy --- cmd/wireproxy/main.go | 2 + config.go | 21 ++++++++ routine.go | 16 ++++++ sni.go | 114 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 sni.go diff --git a/cmd/wireproxy/main.go b/cmd/wireproxy/main.go index 8bb1340b..7834b6a1 100644 --- a/cmd/wireproxy/main.go +++ b/cmd/wireproxy/main.go @@ -148,6 +148,8 @@ func lockNetwork(sections []wireproxy.RoutineSpawner, infoAddr *string) { rules = append(rules, landlock.ConnectTCP(uint16(section.BindAddress.Port))) case *wireproxy.Socks5Config: rules = append(rules, landlock.BindTCP(extractPort(section.BindAddress))) + case *wireproxy.SNIConfig: + rules = append(rules, landlock.BindTCP(extractPort(section.BindAddress))) } } diff --git a/config.go b/config.go index 429e684b..04c516b8 100644 --- a/config.go +++ b/config.go @@ -62,6 +62,10 @@ type Socks5Config struct { Password string } +type SNIConfig struct { + BindAddress string +} + type HTTPConfig struct { BindAddress string Username string @@ -435,6 +439,18 @@ func parseSocks5Config(section *ini.Section) (RoutineSpawner, error) { return config, nil } +func parseSNIConfig(section *ini.Section) (RoutineSpawner, error) { + config := &SNIConfig{} + + bindAddress, err := parseString(section, "BindAddress") + if err != nil { + return nil, err + } + config.BindAddress = bindAddress + + return config, nil +} + func parseHTTPConfig(section *ini.Section) (RoutineSpawner, error) { config := &HTTPConfig{} @@ -591,6 +607,11 @@ func ParseConfig(path string) (*Configuration, error) { return nil, err } + err = parseRoutinesConfig(&routinesSpawners, cfg, "SNI", parseSNIConfig) + if err != nil { + return nil, err + } + if resolveSection, err := cfg.GetSection("Resolve"); err == nil { resolve, err = parseResolveConfig(resolveSection) if err != nil { diff --git a/routine.go b/routine.go index f9214e27..4f0d991c 100644 --- a/routine.go +++ b/routine.go @@ -332,6 +332,22 @@ func (conf *TCPServerTunnelConfig) SpawnRoutine(vt *VirtualTun) { } } +// SpawnRoutine spawns an SNI proxy server. +func (config *SNIConfig) SpawnRoutine(vt *VirtualTun) { + listener, err := net.Listen("tcp", config.BindAddress) + if err != nil { + log.Fatal(err) + } + + for { + conn, err := listener.Accept() + if err != nil { + log.Fatal(err) + } + go sniServe(vt.Tnet.Dial, conn) + } +} + func (d VirtualTun) ServeHTTP(w http.ResponseWriter, r *http.Request) { log.Printf("Health metric request: %s\n", r.URL.Path) switch path.Clean(r.URL.Path) { diff --git a/sni.go b/sni.go new file mode 100644 index 00000000..06a7e035 --- /dev/null +++ b/sni.go @@ -0,0 +1,114 @@ +package wireproxy + +// TLS SNI extraction approach based on Andrew Ayer's sniproxy: +// https://www.agwa.name/blog/post/writing_an_sni_proxy_in_go/media/sniproxy.go + +import ( + "bytes" + "crypto/tls" + "fmt" + "io" + "log" + "net" + "sync" + "time" +) + +// Read only connection to extract ClientHello +type readOnlyConn struct { + reader io.Reader +} + +func (conn readOnlyConn) Read(p []byte) (int, error) { return conn.reader.Read(p) } +func (conn readOnlyConn) Write(p []byte) (int, error) { return 0, io.ErrClosedPipe } +func (conn readOnlyConn) Close() error { return nil } +func (conn readOnlyConn) LocalAddr() net.Addr { return nil } +func (conn readOnlyConn) RemoteAddr() net.Addr { return nil } +func (conn readOnlyConn) SetDeadline(t time.Time) error { return nil } +func (conn readOnlyConn) SetReadDeadline(t time.Time) error { return nil } +func (conn readOnlyConn) SetWriteDeadline(t time.Time) error { return nil } + +// Get ClientHelloInfo from crypto/tls +func readClientHello(reader io.Reader) (*tls.ClientHelloInfo, error) { + var hello *tls.ClientHelloInfo + + err := tls.Server(readOnlyConn{reader: reader}, &tls.Config{ + GetConfigForClient: func(argHello *tls.ClientHelloInfo) (*tls.Config, error) { + hello = new(tls.ClientHelloInfo) + *hello = *argHello + return nil, nil + }, + }).Handshake() + + if hello == nil { + return nil, err + } + + return hello, nil +} + +func peekClientHello(reader io.Reader) (*tls.ClientHelloInfo, io.Reader, error) { + peekedBytes := new(bytes.Buffer) + hello, err := readClientHello(io.TeeReader(reader, peekedBytes)) + if err != nil { + return nil, nil, err + } + return hello, io.MultiReader(peekedBytes, reader), nil +} + +// Get SNI hostname, dial out through tunnel, then proxy data +func sniProxyForward(dial func(string, string) (net.Conn, error), clientConn net.Conn) error { + if err := clientConn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + return fmt.Errorf("set read deadline failed: %w", err) + } + + clientHello, clientReader, err := peekClientHello(clientConn) + if err != nil { + return fmt.Errorf("peek client hello failed: %w", err) + } + + if err := clientConn.SetReadDeadline(time.Time{}); err != nil { + return fmt.Errorf("clear read deadline failed: %w", err) + } + + hostname := clientHello.ServerName + if hostname == "" { + return fmt.Errorf("no SNI hostname in ClientHello") + } + + target := net.JoinHostPort(hostname, "443") + backendConn, err := dial("tcp", target) + if err != nil { + return fmt.Errorf("tun tcp dial failed: %w", err) + } + defer func() { _ = backendConn.Close() }() + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + _, _ = io.Copy(clientConn, backendConn) + if tcpConn, ok := clientConn.(interface{ CloseWrite() error }); ok { + _ = tcpConn.CloseWrite() + } + wg.Done() + }() + go func() { + _, _ = io.Copy(backendConn, clientReader) + if tcpConn, ok := backendConn.(interface{ CloseWrite() error }); ok { + _ = tcpConn.CloseWrite() + } + wg.Done() + }() + + wg.Wait() + return nil +} + +func sniServe(dial func(string, string) (net.Conn, error), conn net.Conn) { + defer func() { _ = conn.Close() }() + + if err := sniProxyForward(dial, conn); err != nil { + log.Printf("SNI proxy: %s\n", err) + } +} From 47e5cd6448179c77e30ce600ad1f84aaf8183611 Mon Sep 17 00:00:00 2001 From: Rob van Oostenrijk Date: Mon, 15 Jun 2026 15:32:37 +0400 Subject: [PATCH 2/5] [upd] SNI documentation --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 9c4e8f9b..bf400c5b 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ of wireproxy by [@artem-russkikh](https://github.com/artem-russkikh). - TCP static routing for client and server - SOCKS5/HTTP proxy (currently only CONNECT is supported) +- Transparent TLS ([SNI](https://en.wikipedia.org/wiki/Server_Name_Indication)) proxy # TODO @@ -171,6 +172,11 @@ BindAddress = 127.0.0.1:25345 # Specifying certificate and key enables HTTPS #CertFile = ... #KeyFile = ... + +# SNI creates a transparent TLS proxy on your LAN, and all traffic would be routed via wireguard, +# using Server Name Indication as routing destination. +[SNI] +BindAddress = 0.0.0.0:443 ``` Alternatively, if you already have a wireguard config, you can import it in the From 127f9e970249d2eae2c3cc3b29be29c1c6227f71 Mon Sep 17 00:00:00 2001 From: Rob van Oostenrijk Date: Tue, 16 Jun 2026 19:36:40 +0400 Subject: [PATCH 3/5] Clean-up sni.go --- sni.go | 41 ++++++++--------------------------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/sni.go b/sni.go index 06a7e035..71947d73 100644 --- a/sni.go +++ b/sni.go @@ -10,11 +10,9 @@ import ( "io" "log" "net" - "sync" "time" ) -// Read only connection to extract ClientHello type readOnlyConn struct { reader io.Reader } @@ -28,11 +26,11 @@ func (conn readOnlyConn) SetDeadline(t time.Time) error { return nil } func (conn readOnlyConn) SetReadDeadline(t time.Time) error { return nil } func (conn readOnlyConn) SetWriteDeadline(t time.Time) error { return nil } -// Get ClientHelloInfo from crypto/tls -func readClientHello(reader io.Reader) (*tls.ClientHelloInfo, error) { +func peekClientHello(conn net.Conn) (*tls.ClientHelloInfo, io.Reader, error) { + peekedBytes := new(bytes.Buffer) var hello *tls.ClientHelloInfo - err := tls.Server(readOnlyConn{reader: reader}, &tls.Config{ + err := tls.Server(readOnlyConn{reader: io.TeeReader(conn, peekedBytes)}, &tls.Config{ GetConfigForClient: func(argHello *tls.ClientHelloInfo) (*tls.Config, error) { hello = new(tls.ClientHelloInfo) *hello = *argHello @@ -40,20 +38,7 @@ func readClientHello(reader io.Reader) (*tls.ClientHelloInfo, error) { }, }).Handshake() - if hello == nil { - return nil, err - } - - return hello, nil -} - -func peekClientHello(reader io.Reader) (*tls.ClientHelloInfo, io.Reader, error) { - peekedBytes := new(bytes.Buffer) - hello, err := readClientHello(io.TeeReader(reader, peekedBytes)) - if err != nil { - return nil, nil, err - } - return hello, io.MultiReader(peekedBytes, reader), nil + return hello, io.MultiReader(peekedBytes, conn), err } // Get SNI hostname, dial out through tunnel, then proxy data @@ -62,7 +47,7 @@ func sniProxyForward(dial func(string, string) (net.Conn, error), clientConn net return fmt.Errorf("set read deadline failed: %w", err) } - clientHello, clientReader, err := peekClientHello(clientConn) + clientHello, peekedClientReader, err := peekClientHello(clientConn) if err != nil { return fmt.Errorf("peek client hello failed: %w", err) } @@ -83,25 +68,15 @@ func sniProxyForward(dial func(string, string) (net.Conn, error), clientConn net } defer func() { _ = backendConn.Close() }() - var wg sync.WaitGroup - wg.Add(2) - go func() { - _, _ = io.Copy(clientConn, backendConn) - if tcpConn, ok := clientConn.(interface{ CloseWrite() error }); ok { - _ = tcpConn.CloseWrite() - } - wg.Done() - }() - go func() { - _, _ = io.Copy(backendConn, clientReader) + _, _ = io.Copy(backendConn, peekedClientReader) if tcpConn, ok := backendConn.(interface{ CloseWrite() error }); ok { _ = tcpConn.CloseWrite() } - wg.Done() }() - wg.Wait() + _, _ = io.Copy(clientConn, backendConn) + return nil } From 68c44a311706bff3ea4c5503bc3364ff7873a4fb Mon Sep 17 00:00:00 2001 From: Rob van Oostenrijk Date: Tue, 16 Jun 2026 20:51:00 +0400 Subject: [PATCH 4/5] Refactor against routines.go --- sni.go | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/sni.go b/sni.go index 71947d73..4c001e6f 100644 --- a/sni.go +++ b/sni.go @@ -26,7 +26,7 @@ func (conn readOnlyConn) SetDeadline(t time.Time) error { return nil } func (conn readOnlyConn) SetReadDeadline(t time.Time) error { return nil } func (conn readOnlyConn) SetWriteDeadline(t time.Time) error { return nil } -func peekClientHello(conn net.Conn) (*tls.ClientHelloInfo, io.Reader, error) { +func peekClientHello(conn net.Conn) (string, io.ReadWriteCloser, error) { peekedBytes := new(bytes.Buffer) var hello *tls.ClientHelloInfo @@ -38,25 +38,39 @@ func peekClientHello(conn net.Conn) (*tls.ClientHelloInfo, io.Reader, error) { }, }).Handshake() - return hello, io.MultiReader(peekedBytes, conn), err + wrappedConn := struct { + io.Reader + io.Writer + io.Closer + }{ + Reader: io.MultiReader(peekedBytes, conn), + Writer: conn, + Closer: conn, + } + + var serverName string + if hello != nil { + serverName = hello.ServerName + } + + return serverName, wrappedConn, err } // Get SNI hostname, dial out through tunnel, then proxy data -func sniProxyForward(dial func(string, string) (net.Conn, error), clientConn net.Conn) error { - if err := clientConn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { +func sniProxyForward(dial func(string, string) (net.Conn, error), conn net.Conn) error { + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { return fmt.Errorf("set read deadline failed: %w", err) } - clientHello, peekedClientReader, err := peekClientHello(clientConn) + hostname, clientConn, err := peekClientHello(conn) if err != nil { return fmt.Errorf("peek client hello failed: %w", err) } - if err := clientConn.SetReadDeadline(time.Time{}); err != nil { + if err := conn.SetReadDeadline(time.Time{}); err != nil { return fmt.Errorf("clear read deadline failed: %w", err) } - hostname := clientHello.ServerName if hostname == "" { return fmt.Errorf("no SNI hostname in ClientHello") } @@ -66,24 +80,16 @@ func sniProxyForward(dial func(string, string) (net.Conn, error), clientConn net if err != nil { return fmt.Errorf("tun tcp dial failed: %w", err) } - defer func() { _ = backendConn.Close() }() - - go func() { - _, _ = io.Copy(backendConn, peekedClientReader) - if tcpConn, ok := backendConn.(interface{ CloseWrite() error }); ok { - _ = tcpConn.CloseWrite() - } - }() - _, _ = io.Copy(clientConn, backendConn) + go connForward(clientConn, backendConn) + go connForward(backendConn, clientConn) return nil } func sniServe(dial func(string, string) (net.Conn, error), conn net.Conn) { - defer func() { _ = conn.Close() }() - if err := sniProxyForward(dial, conn); err != nil { log.Printf("SNI proxy: %s\n", err) + _ = conn.Close() } } From 4362ea4705e1300282d6dd03cda395c3c90376e9 Mon Sep 17 00:00:00 2001 From: Rob van Oostenrijk Date: Wed, 17 Jun 2026 06:46:41 +0400 Subject: [PATCH 5/5] Prevent error when server name was obtained. --- sni.go | 1 + 1 file changed, 1 insertion(+) diff --git a/sni.go b/sni.go index 4c001e6f..ea2ac197 100644 --- a/sni.go +++ b/sni.go @@ -51,6 +51,7 @@ func peekClientHello(conn net.Conn) (string, io.ReadWriteCloser, error) { var serverName string if hello != nil { serverName = hello.ServerName + err = nil } return serverName, wrappedConn, err