net/socks5: correctly proxy half-closed TCP connections

Similar to #16462, when we are acting as a TCP proxy, we need to pass
through half-closes correctly since clients and servers will sometimes
close one direction of the connection and still rely on the other
direction working.

Fixes #20883.

Signed-off-by: Naman Sood <mail@nsood.in>
This commit is contained in:
Naman Sood committed 2026-09-17 17:22:32 -04:00
1 parent 35848427ce
commit 027e249fcf
4 files changed
+151 -3

No files matched your search

+27 -2
View File
@@ -13,6 +13,8 @@
"net"
"sync"
"time"
"tailscale.com/types/nettype"
)
// SplitSOCKSAndHTTP accepts connections on ln and passes connections
@@ -127,11 +129,15 @@ func (ln *listener) Addr() net.Addr {
type connWithOneByte struct {
net.Conn
b byte
bRead bool
b byte
bRead bool
readClosed bool
}
func (c *connWithOneByte) Read(bs []byte) (int, error) {
if c.readClosed {
return 0, net.ErrClosed
}
if c.bRead {
return c.Conn.Read(bs)
}
@@ -142,3 +148,22 @@ func (c *connWithOneByte) Read(bs []byte) (int, error) {
bs[0] = c.b
return 1, nil
}
// CloseRead implements [nettype.HalfCloser], allowing the underlying Conn
// to be half-closed if possible. Otherwise, this is a no-op.
func (c *connWithOneByte) CloseRead() error {
if hc, ok := c.Conn.(nettype.HalfCloser); ok {
c.readClosed = true
return hc.CloseRead()
}
return nil
}
// CloseWrite implements [nettype.HalfCloser], allowing the underlying Conn
// to be half-closed if possible. Otherwise, this is a no-op.
func (c *connWithOneByte) CloseWrite() error {
if hc, ok := c.Conn.(nettype.HalfCloser); ok {
return hc.CloseWrite()
}
return nil
}
+29 -1
View File
@@ -28,6 +28,7 @@
"tailscale.com/syncs"
"tailscale.com/types/logger"
"tailscale.com/types/nettype"
)
// Authentication METHODs described in RFC 1928, section 3.
@@ -239,6 +240,16 @@ func (c *Conn) handleTCP() error {
}
defer srv.Close()
// As of 2026-09-16, `srv.dial` always returns either a TCPConn-type
// connection, or such a connection wrapped by a [tsdial.sysConn],
// which passes down calls to half-close the connection to its
// underlying Conn.
srvHalfCloser, srvIsHalfCloser := srv.(nettype.HalfCloser)
// As of 2026-09-16, `c.clientConn` always originates from a TCP listener,
// sometimes split up by [proxymux.SplitSOCKSAndHTTP], which passes down
// calls to half-close the connection to its underlying Conn.
clientHalfCloser, clientIsHalfCloser := c.clientConn.(nettype.HalfCloser)
localAddr := srv.LocalAddr().String()
serverAddr, serverPort, err := splitHostPort(localAddr)
if err != nil {
@@ -266,6 +277,12 @@ func (c *Conn) handleTCP() error {
if err != nil {
err = fmt.Errorf("from backend to client: %w", err)
}
if clientIsHalfCloser {
err = errors.Join(err, clientHalfCloser.CloseWrite())
}
if srvIsHalfCloser {
err = errors.Join(srvHalfCloser.CloseRead())
}
errc <- err
}()
go func() {
@@ -273,9 +290,20 @@ func (c *Conn) handleTCP() error {
if err != nil {
err = fmt.Errorf("from client to backend: %w", err)
}
if clientIsHalfCloser {
err = errors.Join(err, clientHalfCloser.CloseRead())
}
if srvIsHalfCloser {
err = errors.Join(srvHalfCloser.CloseWrite())
}
errc <- err
}()
return <-errc
// Wait for both sides of the connection to close.
var errs []error
for range 2 {
errs = append(errs, <-errc)
}
return errors.Join(errs...)
}
func (c *Conn) handleUDP() error {
+76
View File
@@ -484,3 +484,79 @@ func TestUDPLogNilLogf(t *testing.T) {
time.Sleep(10 * time.Millisecond)
}
}
func TestTCPHalfClose(t *testing.T) {
const msg = "we are so winning"
// backend server which we'll use SOCKS5 to connect to
listener, err := net.Listen("tcp", ":0")
if err != nil {
t.Fatal(err)
}
backendServerPort := listener.Addr().(*net.TCPAddr).Port
go func() {
c, err := listener.Accept()
if err != nil {
t.Errorf("backend accept conn: %v", err)
}
defer c.Close()
defer listener.Close()
tcpConn := c.(*net.TCPConn)
var buf [1500]byte
n, err := tcpConn.Read(buf[:])
if err != nil {
t.Errorf("backend read: %v", err)
}
res := string(buf[:n])
if res != msg {
t.Errorf("backend read: want %q, got %q", msg, res)
}
if err := tcpConn.CloseRead(); err != nil {
t.Errorf("backend closeread: %v", err)
}
_, err = tcpConn.Write([]byte(msg))
if err != nil {
t.Errorf("backend write: %v", err)
}
}()
// SOCKS5 server
socks5, err := net.Listen("tcp", ":0")
if err != nil {
t.Fatal(err)
}
socks5Port := socks5.Addr().(*net.TCPAddr).Port
go socks5Server(socks5)
// Client
addr := fmt.Sprintf("localhost:%d", socks5Port)
socksDialer, err := proxy.SOCKS5("tcp", addr, nil, proxy.Direct)
if err != nil {
t.Fatal(err)
}
addr = fmt.Sprintf("localhost:%d", backendServerPort)
conn, err := socksDialer.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
tcpConn := conn.(*net.TCPConn)
_, err = tcpConn.Write([]byte(msg))
if err != nil {
t.Errorf("client write: %v", err)
}
if err := tcpConn.CloseWrite(); err != nil {
t.Errorf("client closewrite: %v", err)
}
var buf [1500]byte
n, err := tcpConn.Read(buf[:])
if err != nil {
t.Errorf("client read: %v", err)
}
res := string(buf[:n])
if res != msg {
t.Errorf("client read: want %q, got %q", msg, res)
}
}
+19
View File
@@ -32,6 +32,7 @@
"tailscale.com/net/tsaddr"
"tailscale.com/syncs"
"tailscale.com/types/logger"
"tailscale.com/types/nettype"
"tailscale.com/util/clientmetric"
"tailscale.com/util/eventbus"
"tailscale.com/util/mak"
@@ -125,6 +126,24 @@ func (c sysConn) Close() error {
return nil
}
// CloseRead implements [nettype.HalfCloser], allowing the underlying Conn
// to be half-closed if possible. Otherwise, this is a no-op.
func (c sysConn) CloseRead() error {
if hc, ok := c.Conn.(nettype.HalfCloser); ok {
return hc.CloseRead()
}
return nil
}
// CloseWrite implements [nettype.HalfCloser], allowing the underlying Conn
// to be half-closed if possible. Otherwise, this is a no-op.
func (c sysConn) CloseWrite() error {
if hc, ok := c.Conn.(nettype.HalfCloser); ok {
return hc.CloseWrite()
}
return nil
}
// SetTUNName sets the name of the tun device in use ("tailscale0", "utun6",
// etc). This is needed on some platforms to set sockopts to bind
// to the same interface index.